From 16ff9ad10d0717296cfc631856e477661fd8db13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 1 Aug 2020 13:28:36 +0200 Subject: [PATCH] Use round robin for happy eyeballs DNS responses (load balancing) The happy eyeballs algorithms tries to connect over both IPv6 and IPv4 at the same time. Accordingly, the hostname has to be resolved for both address families which both may potentially contain any number of records (load balancing). This changeset randomizes the order of returned IP addresses per address family. This means that if multiple records are returned, it will try to connect to a random one from this list instead of always trying the first. This allows the load to be distributed more evenly across all returned IP addresses. This can be used as a very basic DNS load balancing mechanism. --- src/HappyEyeBallsConnectionBuilder.php | 1 + tests/HappyEyeBallsConnectionBuilderTest.php | 64 ++++++++++++++++++-- tests/HappyEyeBallsConnectorTest.php | 44 -------------- 3 files changed, 59 insertions(+), 50 deletions(-) diff --git a/src/HappyEyeBallsConnectionBuilder.php b/src/HappyEyeBallsConnectionBuilder.php index 9b472ac1..3c7d5c8d 100644 --- a/src/HappyEyeBallsConnectionBuilder.php +++ b/src/HappyEyeBallsConnectionBuilder.php @@ -316,6 +316,7 @@ public function hasBeenResolved() */ public function mixIpsIntoConnectQueue(array $ips) { + \shuffle($ips); $this->ipsCount += \count($ips); $connectQueueStash = $this->connectQueue; $this->connectQueue = array(); diff --git a/tests/HappyEyeBallsConnectionBuilderTest.php b/tests/HappyEyeBallsConnectionBuilderTest.php index e9b932ff..80c118ba 100644 --- a/tests/HappyEyeBallsConnectionBuilderTest.php +++ b/tests/HappyEyeBallsConnectionBuilderTest.php @@ -302,8 +302,8 @@ public function testConnectWillStartConnectingWithAlternatingIPv6AndIPv4WhenReso $connector->expects($this->exactly(4))->method('connect')->withConsecutive( array('tcp://[::1]:80?hostname=reactphp.org'), array('tcp://127.0.0.1:80?hostname=reactphp.org'), - array('tcp://[::2]:80?hostname=reactphp.org'), - array('tcp://127.0.0.2:80?hostname=reactphp.org') + array('tcp://[::1]:80?hostname=reactphp.org'), + array('tcp://127.0.0.1:80?hostname=reactphp.org') )->willReturnOnConsecutiveCalls( $deferred->promise(), $deferred->promise(), @@ -316,8 +316,8 @@ public function testConnectWillStartConnectingWithAlternatingIPv6AndIPv4WhenReso array('reactphp.org', Message::TYPE_AAAA), array('reactphp.org', Message::TYPE_A) )->willReturnOnConsecutiveCalls( - \React\Promise\resolve(array('::1', '::2')), - \React\Promise\resolve(array('127.0.0.1', '127.0.0.2')) + \React\Promise\resolve(array('::1', '::1')), + \React\Promise\resolve(array('127.0.0.1', '127.0.0.1')) ); $uri = 'tcp://reactphp.org:80'; @@ -341,7 +341,7 @@ public function testConnectWillStartConnectingWithAttemptTimerWhenOnlyIpv6Resolv $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); $connector->expects($this->exactly(2))->method('connect')->withConsecutive( array('tcp://[::1]:80?hostname=reactphp.org'), - array('tcp://[::2]:80?hostname=reactphp.org') + array('tcp://[::1]:80?hostname=reactphp.org') )->willReturnOnConsecutiveCalls( \React\Promise\reject(new \RuntimeException()), new Promise(function () { }) @@ -352,7 +352,7 @@ public function testConnectWillStartConnectingWithAttemptTimerWhenOnlyIpv6Resolv array('reactphp.org', Message::TYPE_AAAA), array('reactphp.org', Message::TYPE_A) )->willReturnOnConsecutiveCalls( - \React\Promise\resolve(array('::1', '::2')), + \React\Promise\resolve(array('::1', '::1')), \React\Promise\reject(new \RuntimeException()) ); @@ -799,4 +799,56 @@ public function testCleanUpCancelsAllPendingConnectionAttemptsWithoutStartingNew $builder->cleanUp(); } + + public function testMixIpsIntoConnectQueueSometimesAssignsInOriginalOrder() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + for ($i = 0; $i < 100; ++$i) { + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + $builder->mixIpsIntoConnectQueue(array('::1', '::2')); + + $ref = new \ReflectionProperty($builder, 'connectQueue'); + $ref->setAccessible(true); + $value = $ref->getValue($builder); + + if ($value === array('::1', '::2')) { + break; + } + } + + $this->assertEquals(array('::1', '::2'), $value); + } + + public function testMixIpsIntoConnectQueueSometimesAssignsInReverseOrder() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + for ($i = 0; $i < 100; ++$i) { + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + $builder->mixIpsIntoConnectQueue(array('::1', '::2')); + + $ref = new \ReflectionProperty($builder, 'connectQueue'); + $ref->setAccessible(true); + $value = $ref->getValue($builder); + + if ($value === array('::2', '::1')) { + break; + } + } + + $this->assertEquals(array('::2', '::1'), $value); + } } diff --git a/tests/HappyEyeBallsConnectorTest.php b/tests/HappyEyeBallsConnectorTest.php index 2ed40b25..6af7807a 100644 --- a/tests/HappyEyeBallsConnectorTest.php +++ b/tests/HappyEyeBallsConnectorTest.php @@ -270,50 +270,6 @@ public function testCancelDuringTcpConnectionCancelsTcpConnectionIfGivenIp() $this->loop->run(); } - /** - * @dataProvider provideIpvAddresses - */ - public function testShouldConnectOverIpv4WhenIpv6LookupFails(array $ipv6, array $ipv4) - { - $this->resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( - array($this->equalTo('example.com'), Message::TYPE_AAAA), - array($this->equalTo('example.com'), Message::TYPE_A) - )->willReturnOnConsecutiveCalls( - Promise\reject(new \Exception('failure')), - Promise\resolve($ipv4) - ); - $this->tcp->expects($this->exactly(1))->method('connect')->with($this->equalTo('1.2.3.4:80?hostname=example.com'))->willReturn(Promise\resolve($this->connection)); - - $promise = $this->connector->connect('example.com:80');; - $resolvedConnection = Block\await($promise, $this->loop); - - self::assertSame($this->connection, $resolvedConnection); - } - - /** - * @dataProvider provideIpvAddresses - */ - public function testShouldConnectOverIpv6WhenIpv4LookupFails(array $ipv6, array $ipv4) - { - if (count($ipv6) === 0) { - $ipv6[] = '1:2:3:4'; - } - - $this->resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( - array($this->equalTo('example.com'), Message::TYPE_AAAA), - array($this->equalTo('example.com'), Message::TYPE_A) - )->willReturnOnConsecutiveCalls( - Promise\resolve($ipv6), - Promise\reject(new \Exception('failure')) - ); - $this->tcp->expects($this->exactly(1))->method('connect')->with($this->equalTo('[1:2:3:4]:80?hostname=example.com'))->willReturn(Promise\resolve($this->connection)); - - $promise = $this->connector->connect('example.com:80');; - $resolvedConnection = Block\await($promise, $this->loop); - - self::assertSame($this->connection, $resolvedConnection); - } - /** * @internal */