From 740e97a13fa8b1d313a159e62abd0611e69374f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 9 Nov 2018 14:17:16 +0100 Subject: [PATCH 1/3] Keep track of underlying connection and create new when connection lost --- README.md | 35 ++++++++----- src/Factory.php | 35 ++++++++----- src/Io/LazyConnection.php | 43 +++++++-------- tests/FactoryTest.php | 8 +-- tests/Io/LazyConnectionTest.php | 92 +++++++++++++++++++++++++++------ 5 files changed, 147 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index d06dc06..b36dbc9 100644 --- a/README.md +++ b/README.md @@ -171,24 +171,33 @@ $connection->query(…); This method immediately returns a "virtual" connection implementing the [`ConnectionInterface`](#connectioninterface) that can be used to interface with your MySQL database. Internally, it lazily creates the -underlying database connection (which may take some time) only once the -first request is invoked on this instance and will queue all outstanding -requests until the underlying connection is ready. +underlying database connection only on demand once the first request is +invoked on this instance and will queue all outstanding requests until +the underlying connection is ready. Additionally, it will keep track of +this underlying connection and will create a new underlying connection +on demand when the current connection is lost. From a consumer side this means that you can start sending queries to the -database right away while the actual connection may still be outstanding. -It will ensure that all commands will be executed in the order they are -enqueued once the connection is ready. If the database connection fails, -it will emit an `error` event, reject all outstanding commands and `close` -the connection as described in the `ConnectionInterface`. In other words, -it behaves just like a real connection and frees you from having to deal -with its async resolution. +database right away while the underlying connection may still be +outstanding. Because creating this underlying connection may take some +time, it will enqueue all oustanding commands and will ensure that all +commands will be executed in correct order once the connection is ready. +In other words, this "virtual" connection behaves just like a "real" +connection as described in the `ConnectionInterface` and frees you from +having to deal with its async resolution. + +If the underlying database connection fails, it will reject all +outstanding commands and will return to the initial "idle" state. This +means that you can keep sending additional commands at a later time which +will again try to open the underlying connection. Note that creating the underlying connection will be deferred until the first request is invoked. Accordingly, any eventual connection issues -will be detected once this instance is first used. Similarly, calling -`quit()` on this instance before invoking any requests will succeed -immediately and will not wait for an actual underlying connection. +will be detected once this instance is first used. You can use the +`quit()` method to ensure that the "virtual" connection will be soft-closed +and no further commands can be enqueued. Similarly, calling `quit()` on +this instance before invoking any requests will succeed immediately and +will not wait for an actual underlying connection. Depending on your particular use case, you may prefer this method or the underlying `createConnection()` which resolves with a promise. For many diff --git a/src/Factory.php b/src/Factory.php index 263358a..007c05c 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -211,24 +211,33 @@ public function createConnection($uri) * This method immediately returns a "virtual" connection implementing the * [`ConnectionInterface`](#connectioninterface) that can be used to * interface with your MySQL database. Internally, it lazily creates the - * underlying database connection (which may take some time) only once the - * first request is invoked on this instance and will queue all outstanding - * requests until the underlying connection is ready. + * underlying database connection only on demand once the first request is + * invoked on this instance and will queue all outstanding requests until + * the underlying connection is ready. Additionally, it will keep track of + * this underlying connection and will create a new underlying connection + * on demand when the current connection is lost. * * From a consumer side this means that you can start sending queries to the - * database right away while the actual connection may still be outstanding. - * It will ensure that all commands will be executed in the order they are - * enqueued once the connection is ready. If the database connection fails, - * it will emit an `error` event, reject all outstanding commands and `close` - * the connection as described in the `ConnectionInterface`. In other words, - * it behaves just like a real connection and frees you from having to deal - * with its async resolution. + * database right away while the underlying connection may still be + * outstanding. Because creating this underlying connection may take some + * time, it will enqueue all oustanding commands and will ensure that all + * commands will be executed in correct order once the connection is ready. + * In other words, this "virtual" connection behaves just like a "real" + * connection as described in the `ConnectionInterface` and frees you from + * having to deal with its async resolution. + * + * If the underlying database connection fails, it will reject all + * outstanding commands and will return to the initial "idle" state. This + * means that you can keep sending additional commands at a later time which + * will again try to open the underlying connection. * * Note that creating the underlying connection will be deferred until the * first request is invoked. Accordingly, any eventual connection issues - * will be detected once this instance is first used. Similarly, calling - * `quit()` on this instance before invoking any requests will succeed - * immediately and will not wait for an actual underlying connection. + * will be detected once this instance is first used. You can use the + * `quit()` method to ensure that the "virtual" connection will be soft-closed + * and no further commands can be enqueued. Similarly, calling `quit()` on + * this instance before invoking any requests will succeed immediately and + * will not wait for an actual underlying connection. * * Depending on your particular use case, you may prefer this method or the * underlying `createConnection()` which resolves with a promise. For many diff --git a/src/Io/LazyConnection.php b/src/Io/LazyConnection.php index ad55e87..d6b2156 100644 --- a/src/Io/LazyConnection.php +++ b/src/Io/LazyConnection.php @@ -27,29 +27,22 @@ public function __construct(Factory $factory, $uri) private function connecting() { - if ($this->connecting === null) { - $this->connecting = $this->factory->createConnection($this->uri); - - $this->connecting->then(function (ConnectionInterface $connection) { - // connection completed => forward error and close events - $connection->on('error', function ($e) { - $this->emit('error', [$e]); - }); - $connection->on('close', function () { - $this->close(); - }); - }, function (\Exception $e) { - // connection failed => emit error if connection is not already closed - if ($this->closed) { - return; - } + if ($this->connecting !== null) { + return $this->connecting; + } - $this->emit('error', [$e]); - $this->close(); + $this->connecting = $connecting = $this->factory->createConnection($this->uri); + $this->connecting->then(function (ConnectionInterface $connection) { + // connection completed => remember only until closed + $connection->on('close', function () { + $this->connecting = null; }); - } + }, function () { + // connection failed => discard connection attempt + $this->connecting = null; + }); - return $this->connecting; + return $connecting; } public function query($sql, array $params = []) @@ -100,7 +93,15 @@ public function quit() } return $this->connecting()->then(function (ConnectionInterface $connection) { - return $connection->quit(); + return $connection->quit()->then( + function () { + $this->close(); + }, + function (\Exception $e) { + $this->close(); + throw $e; + } + ); }); } diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index 9f189e7..7c0f3bb 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -387,7 +387,7 @@ public function testConnectLazyWithValidAuthWillRunUntilQuitAfterPing() $loop->run(); } - public function testConnectLazyWithInvalidAuthWillEmitErrorAndCloseAfterPing() + public function testConnectLazyWithInvalidAuthWillRejectPingButWillNotEmitErrorOrClose() { $loop = \React\EventLoop\Factory::create(); $factory = new Factory($loop); @@ -395,10 +395,10 @@ public function testConnectLazyWithInvalidAuthWillEmitErrorAndCloseAfterPing() $uri = $this->getConnectionString(array('passwd' => 'invalidpass')); $connection = $factory->createLazyConnection($uri); - $connection->on('error', $this->expectCallableOnce()); - $connection->on('close', $this->expectCallableOnce()); + $connection->on('error', $this->expectCallableNever()); + $connection->on('close', $this->expectCallableNever()); - $connection->ping(); + $connection->ping()->then(null, $this->expectCallableOnce()); $loop->run(); } diff --git a/tests/Io/LazyConnectionTest.php b/tests/Io/LazyConnectionTest.php index d770e41..28eec81 100644 --- a/tests/Io/LazyConnectionTest.php +++ b/tests/Io/LazyConnectionTest.php @@ -12,51 +12,47 @@ class LazyConnectionTest extends BaseTestCase { - public function testPingWillCloseConnectionWithErrorWhenPendingConnectionFails() + public function testPingWillNotCloseConnectionWhenPendingConnectionFails() { $deferred = new Deferred(); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); $connection = new LazyConnection($factory, ''); - $connection->on('error', $this->expectCallableOnce()); - $connection->on('close', $this->expectCallableOnce()); + $connection->on('error', $this->expectCallableNever()); + $connection->on('close', $this->expectCallableNever()); $connection->ping(); $deferred->reject(new \RuntimeException()); } - public function testPingWillCloseConnectionWithoutErrorWhenUnderlyingConnectionCloses() + public function testPingWillNotCloseConnectionWhenUnderlyingConnectionCloses() { - $promise = new Promise(function () { }); - $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); - $factory->expects($this->once())->method('createConnection')->willReturn($promise); - $base = new LazyConnection($factory, ''); + $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping'))->disableOriginalConstructor()->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); $connection = new LazyConnection($factory, ''); $connection->on('error', $this->expectCallableNever()); - $connection->on('close', $this->expectCallableOnce()); + $connection->on('close', $this->expectCallableNever()); $connection->ping(); $base->close(); } - public function testPingWillForwardErrorFromUnderlyingConnection() + public function testPingWillNotForwardErrorFromUnderlyingConnection() { - $promise = new Promise(function () { }); - $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); - $factory->expects($this->once())->method('createConnection')->willReturn($promise); - $base = new LazyConnection($factory, ''); + $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping'))->disableOriginalConstructor()->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); $connection = new LazyConnection($factory, ''); - $connection->on('error', $this->expectCallableOnce()); + $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableNever()); $connection->ping(); @@ -178,6 +174,33 @@ public function testPingWillPingUnderlyingConnectionWhenResolved() $connection->ping(); } + public function testPingTwiceWillBothRejectWithSameErrorWhenUnderlyingConnectionRejects() + { + $error = new \RuntimeException(); + $deferred = new Deferred(); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); + $connection = new LazyConnection($factory, ''); + + $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); + $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); + + $deferred->reject($error); + } + + public function testPingWillTryToCreateNewUnderlyingConnectionAfterPreviousPingFailedToCreateUnderlyingConnection() + { + $error = new \RuntimeException(); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->exactly(2))->method('createConnection')->willReturn(\React\Promise\reject($error)); + $connection = new LazyConnection($factory, ''); + + $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); + $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); + } + public function testQuitResolvesAndEmitsCloseImmediatelyWhenConnectionIsNotAlreadyPending() { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); @@ -220,6 +243,45 @@ public function testQuitAfterPingWillQuitUnderlyingConnectionWhenResolved() $connection->quit(); } + public function testQuitAfterPingResolvesAndEmitsCloseWhenUnderlyingConnectionQuits() + { + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + $connection = new LazyConnection($factory, ''); + + $connection->on('close', $this->expectCallableOnce()); + + $connection->ping(); + $ret = $connection->quit(); + + $this->assertTrue($ret instanceof PromiseInterface); + $ret->then($this->expectCallableOnce(), $this->expectCallableNever()); + } + + public function testQuitAfterPingRejectsAndEmitsCloseWhenUnderlyingConnectionFailsToQuit() + { + $error = new \RuntimeException(); + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(\React\Promise\reject($error)); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + $connection = new LazyConnection($factory, ''); + + $connection->on('close', $this->expectCallableOnce()); + + $connection->ping(); + $ret = $connection->quit(); + + $this->assertTrue($ret instanceof PromiseInterface); + $ret->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); + } + public function testCloseEmitsCloseImmediatelyWhenConnectionIsNotAlreadyPending() { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); From 160b3f23f9a009ee7010437cb4d3c7d8dd992edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 9 Nov 2018 18:42:15 +0100 Subject: [PATCH 2/3] Implement "idle" timeout to close underlying connection when unused --- README.md | 29 ++- composer.json | 2 +- src/ConnectionInterface.php | 2 +- src/Factory.php | 29 ++- src/Io/LazyConnection.php | 84 +++++++- tests/FactoryTest.php | 13 ++ tests/Io/LazyConnectionTest.php | 352 +++++++++++++++++++++++++++++--- 7 files changed, 457 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b36dbc9..6891c30 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,9 @@ This method immediately returns a "virtual" connection implementing the interface with your MySQL database. Internally, it lazily creates the underlying database connection only on demand once the first request is invoked on this instance and will queue all outstanding requests until -the underlying connection is ready. Additionally, it will keep track of -this underlying connection and will create a new underlying connection -on demand when the current connection is lost. +the underlying connection is ready. Additionally, it will only keep this +underlying connection in an "idle" state for 60s by default and will +automatically end the underlying connection when it is no longer needed. From a consumer side this means that you can start sending queries to the database right away while the underlying connection may still be @@ -189,15 +189,17 @@ having to deal with its async resolution. If the underlying database connection fails, it will reject all outstanding commands and will return to the initial "idle" state. This means that you can keep sending additional commands at a later time which -will again try to open the underlying connection. +will again try to open a new underlying connection. Note that this may +require special care if you're using transactions that are kept open for +longer than the idle period. Note that creating the underlying connection will be deferred until the first request is invoked. Accordingly, any eventual connection issues will be detected once this instance is first used. You can use the `quit()` method to ensure that the "virtual" connection will be soft-closed and no further commands can be enqueued. Similarly, calling `quit()` on -this instance before invoking any requests will succeed immediately and -will not wait for an actual underlying connection. +this instance when not currently connected will succeed immediately and +will not have to wait for an actual underlying connection. Depending on your particular use case, you may prefer this method or the underlying `createConnection()` which resolves with a promise. For many @@ -234,6 +236,19 @@ in seconds (or use a negative number to not apply a timeout) like this: $factory->createLazyConnection('localhost?timeout=0.5'); ``` +By default, this method will keep "idle" connection open for 60s and will +then end the underlying connection. The next request after an "idle" +connection ended will automatically create a new underlying connection. +This ensure you always get a "fresh" connection and as such should not be +confused with a "keepalive" or "heartbeat" mechanism, as this will not +actively try to probe the connection. You can explicitly pass a custom +idle timeout value in seconds (or use a negative number to not apply a +timeout) like this: + +```php +$factory->createLazyConnection('localhost?idle=0.1'); +``` + ### ConnectionInterface The `ConnectionInterface` represents a connection that is responsible for @@ -435,7 +450,7 @@ $connecion->on('close', function () { }); ``` -See also the [#close](#close) method. +See also the [`close()`](#close) method. ## Install diff --git a/composer.json b/composer.json index a119f83..7ac4417 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": ">=5.4.0", "evenement/evenement": "^3.0 || ^2.1 || ^1.1", - "react/event-loop": "^1.0 || ^0.5 || ^0.4", + "react/event-loop": "^1.0 || ^0.5", "react/promise": "^2.7", "react/promise-stream": "^1.1", "react/promise-timer": "^1.5", diff --git a/src/ConnectionInterface.php b/src/ConnectionInterface.php index 874725c..46a3fc8 100644 --- a/src/ConnectionInterface.php +++ b/src/ConnectionInterface.php @@ -38,7 +38,7 @@ * }); * ``` * - * See also the [#close](#close) method. + * See also the [`close()`](#close) method. */ interface ConnectionInterface extends EventEmitterInterface { diff --git a/src/Factory.php b/src/Factory.php index 007c05c..6e1a38d 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -213,9 +213,9 @@ public function createConnection($uri) * interface with your MySQL database. Internally, it lazily creates the * underlying database connection only on demand once the first request is * invoked on this instance and will queue all outstanding requests until - * the underlying connection is ready. Additionally, it will keep track of - * this underlying connection and will create a new underlying connection - * on demand when the current connection is lost. + * the underlying connection is ready. Additionally, it will only keep this + * underlying connection in an "idle" state for 60s by default and will + * automatically end the underlying connection when it is no longer needed. * * From a consumer side this means that you can start sending queries to the * database right away while the underlying connection may still be @@ -229,15 +229,17 @@ public function createConnection($uri) * If the underlying database connection fails, it will reject all * outstanding commands and will return to the initial "idle" state. This * means that you can keep sending additional commands at a later time which - * will again try to open the underlying connection. + * will again try to open a new underlying connection. Note that this may + * require special care if you're using transactions that are kept open for + * longer than the idle period. * * Note that creating the underlying connection will be deferred until the * first request is invoked. Accordingly, any eventual connection issues * will be detected once this instance is first used. You can use the * `quit()` method to ensure that the "virtual" connection will be soft-closed * and no further commands can be enqueued. Similarly, calling `quit()` on - * this instance before invoking any requests will succeed immediately and - * will not wait for an actual underlying connection. + * this instance when not currently connected will succeed immediately and + * will not have to wait for an actual underlying connection. * * Depending on your particular use case, you may prefer this method or the * underlying `createConnection()` which resolves with a promise. For many @@ -274,11 +276,24 @@ public function createConnection($uri) * $factory->createLazyConnection('localhost?timeout=0.5'); * ``` * + * By default, this method will keep "idle" connection open for 60s and will + * then end the underlying connection. The next request after an "idle" + * connection ended will automatically create a new underlying connection. + * This ensure you always get a "fresh" connection and as such should not be + * confused with a "keepalive" or "heartbeat" mechanism, as this will not + * actively try to probe the connection. You can explicitly pass a custom + * idle timeout value in seconds (or use a negative number to not apply a + * timeout) like this: + * + * ```php + * $factory->createLazyConnection('localhost?idle=0.1'); + * ``` + * * @param string $uri * @return ConnectionInterface */ public function createLazyConnection($uri) { - return new LazyConnection($this, $uri); + return new LazyConnection($this, $uri, $this->loop); } } diff --git a/src/Io/LazyConnection.php b/src/Io/LazyConnection.php index d6b2156..5957d60 100644 --- a/src/Io/LazyConnection.php +++ b/src/Io/LazyConnection.php @@ -6,6 +6,8 @@ use Evenement\EventEmitter; use React\MySQL\Exception; use React\MySQL\Factory; +use React\EventLoop\LoopInterface; +use React\MySQL\QueryResult; /** * @internal @@ -19,10 +21,22 @@ class LazyConnection extends EventEmitter implements ConnectionInterface private $closed = false; private $busy = false; - public function __construct(Factory $factory, $uri) + private $loop; + private $idlePeriod = 60.0; + private $idleTimer; + private $pending = 0; + + public function __construct(Factory $factory, $uri, LoopInterface $loop) { + $args = array(); + \parse_str(\parse_url($uri, \PHP_URL_QUERY), $args); + if (isset($args['idle'])) { + $this->idlePeriod = (float)$args['idle']; + } + $this->factory = $factory; $this->uri = $uri; + $this->loop = $loop; } private function connecting() @@ -36,6 +50,11 @@ private function connecting() // connection completed => remember only until closed $connection->on('close', function () { $this->connecting = null; + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } }); }, function () { // connection failed => discard connection attempt @@ -45,6 +64,31 @@ private function connecting() return $connecting; } + private function awake() + { + ++$this->pending; + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + } + + private function idle() + { + --$this->pending; + + if ($this->pending < 1 && $this->idlePeriod >= 0) { + $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () { + $this->connecting->then(function (ConnectionInterface $connection) { + $connection->quit(); + }); + $this->connecting = null; + $this->idleTimer = null; + }); + } + } + public function query($sql, array $params = []) { if ($this->closed) { @@ -52,7 +96,17 @@ public function query($sql, array $params = []) } return $this->connecting()->then(function (ConnectionInterface $connection) use ($sql, $params) { - return $connection->query($sql, $params); + $this->awake(); + return $connection->query($sql, $params)->then( + function (QueryResult $result) { + $this->idle(); + return $result; + }, + function (\Exception $e) { + $this->idle(); + throw $e; + } + ); }); } @@ -64,7 +118,14 @@ public function queryStream($sql, $params = []) return \React\Promise\Stream\unwrapReadable( $this->connecting()->then(function (ConnectionInterface $connection) use ($sql, $params) { - return $connection->queryStream($sql, $params); + $stream = $connection->queryStream($sql, $params); + + $this->awake(); + $stream->on('close', function () { + $this->idle(); + }); + + return $stream; }) ); } @@ -76,7 +137,16 @@ public function ping() } return $this->connecting()->then(function (ConnectionInterface $connection) { - return $connection->ping(); + $this->awake(); + return $connection->ping()->then( + function () { + $this->idle(); + }, + function (\Exception $e) { + $this->idle(); + throw $e; + } + ); }); } @@ -93,6 +163,7 @@ public function quit() } return $this->connecting()->then(function (ConnectionInterface $connection) { + $this->awake(); return $connection->quit()->then( function () { $this->close(); @@ -122,6 +193,11 @@ public function close() $this->connecting = null; } + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + $this->emit('close'); $this->removeAllListeners(); } diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index 7c0f3bb..dd95164 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -387,6 +387,19 @@ public function testConnectLazyWithValidAuthWillRunUntilQuitAfterPing() $loop->run(); } + public function testConnectLazyWithValidAuthWillRunUntilIdleTimerAfterPingEvenWithoutQuit() + { + $loop = \React\EventLoop\Factory::create(); + $factory = new Factory($loop); + + $uri = $this->getConnectionString() . '?idle=0'; + $connection = $factory->createLazyConnection($uri); + + $connection->ping(); + + $loop->run(); + } + public function testConnectLazyWithInvalidAuthWillRejectPingButWillNotEmitErrorOrClose() { $loop = \React\EventLoop\Factory::create(); diff --git a/tests/Io/LazyConnectionTest.php b/tests/Io/LazyConnectionTest.php index 28eec81..8f2b8e9 100644 --- a/tests/Io/LazyConnectionTest.php +++ b/tests/Io/LazyConnectionTest.php @@ -9,6 +9,7 @@ use React\Tests\MySQL\BaseTestCase; use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; +use React\MySQL\QueryResult; class LazyConnectionTest extends BaseTestCase { @@ -17,7 +18,8 @@ public function testPingWillNotCloseConnectionWhenPendingConnectionFails() $deferred = new Deferred(); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableNever()); @@ -26,7 +28,6 @@ public function testPingWillNotCloseConnectionWhenPendingConnectionFails() $deferred->reject(new \RuntimeException()); } - public function testPingWillNotCloseConnectionWhenUnderlyingConnectionCloses() { $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping'))->disableOriginalConstructor()->getMock(); @@ -34,7 +35,8 @@ public function testPingWillNotCloseConnectionWhenUnderlyingConnectionCloses() $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableNever()); @@ -43,6 +45,27 @@ public function testPingWillNotCloseConnectionWhenUnderlyingConnectionCloses() $base->close(); } + public function testPingWillCancelTimerWithoutClosingConnectionWhenUnderlyingConnectionCloses() + { + $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping'))->disableOriginalConstructor()->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connection = new LazyConnection($factory, '', $loop); + + $connection->on('close', $this->expectCallableNever()); + + $connection->ping(); + $base->close(); + } + public function testPingWillNotForwardErrorFromUnderlyingConnection() { $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping'))->disableOriginalConstructor()->getMock(); @@ -50,7 +73,8 @@ public function testPingWillNotForwardErrorFromUnderlyingConnection() $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableNever()); @@ -60,12 +84,43 @@ public function testPingWillNotForwardErrorFromUnderlyingConnection() $base->emit('error', [new \RuntimeException()]); } - public function testQueryReturnsPendingPromiseWhenConnectionIsPending() + public function testPingFollowedByIdleTimerWillQuitUnderlyingConnection() + { + $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping', 'quit'))->disableOriginalConstructor()->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $connection = new LazyConnection($factory, '', $loop); + + $connection->on('close', $this->expectCallableNever()); + + $connection->ping(); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testQueryReturnsPendingPromiseAndWillNotStartTimerWhenConnectionIsPending() { $deferred = new Deferred(); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); - $connection = new LazyConnection($factory, ''); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); $ret = $connection->query('SELECT 1'); @@ -76,21 +131,147 @@ public function testQueryReturnsPendingPromiseWhenConnectionIsPending() public function testQueryWillQueryUnderlyingConnectionWhenResolved() { $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); - $base->expects($this->once())->method('query')->with('SELECT 1'); + $base->expects($this->once())->method('query')->with('SELECT 1')->willReturn(new Promise(function () { })); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->query('SELECT 1'); } - public function testQueryWillRejectWhenUnderlyingConnectionRejects() + public function testQueryWillResolveAndStartTimerWithDefaultIntervalWhenQueryFromUnderlyingConnectionResolves() + { + $result = new QueryResult(); + + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('query')->with('SELECT 1')->willReturn(\React\Promise\resolve($result)); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(60.0, $this->anything()); + + $connection = new LazyConnection($factory, '', $loop); + + $ret = $connection->query('SELECT 1'); + $ret->then($this->expectCallableOnceWith($result), $this->expectCallableNever()); + } + + public function testQueryWillResolveAndStartTimerWithIntervalFromIdleParameterWhenQueryFromUnderlyingConnectionResolves() + { + $result = new QueryResult(); + + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('query')->with('SELECT 1')->willReturn(\React\Promise\resolve($result)); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(2.5, $this->anything()); + + $connection = new LazyConnection($factory, 'mysql://localhost?idle=2.5', $loop); + + $ret = $connection->query('SELECT 1'); + $ret->then($this->expectCallableOnceWith($result), $this->expectCallableNever()); + } + + public function testQueryWillResolveWithoutStartingTimerWhenQueryFromUnderlyingConnectionResolvesAndIdleParameterIsNegative() + { + $result = new QueryResult(); + + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('query')->with('SELECT 1')->willReturn(\React\Promise\resolve($result)); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $connection = new LazyConnection($factory, 'mysql://localhost?idle=-1', $loop); + + $ret = $connection->query('SELECT 1'); + $ret->then($this->expectCallableOnceWith($result), $this->expectCallableNever()); + } + + public function testQueryBeforePingWillResolveWithoutStartingTimerWhenQueryFromUnderlyingConnectionResolvesBecausePingIsStillPending() + { + $result = new QueryResult(); + $deferred = new Deferred(); + + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('query')->with('SELECT 1')->willReturn($deferred->promise()); + $base->expects($this->once())->method('ping')->willReturn(new Promise(function () { })); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); + + $ret = $connection->query('SELECT 1'); + $connection->ping(); + + $deferred->resolve($result); + + $ret->then($this->expectCallableOnceWith($result), $this->expectCallableNever()); + } + + public function testQueryAfterPingWillCancelTimerAgainWhenPingFromUnderlyingConnectionResolved() + { + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('query')->with('SELECT 1')->willReturn(new Promise(function () { })); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connection = new LazyConnection($factory, '', $loop); + + $connection->ping(); + $connection->query('SELECT 1'); + } + + public function testQueryWillRejectAndStartTimerWhenQueryFromUnderlyingConnectionRejects() + { + $error = new \RuntimeException(); + + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('query')->with('SELECT 1')->willReturn(\React\Promise\reject($error)); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); + + $ret = $connection->query('SELECT 1'); + $ret->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); + } + + public function testQueryWillRejectWithoutStartingTimerWhenUnderlyingConnectionRejects() { $deferred = new Deferred(); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); - $connection = new LazyConnection($factory, ''); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); $ret = $connection->query('SELECT 1'); $ret->then($this->expectCallableNever(), $this->expectCallableOnce()); @@ -103,7 +284,8 @@ public function testQueryStreamReturnsReadableStreamWhenConnectionIsPending() $promise = new Promise(function () { }); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($promise); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $ret = $connection->queryStream('SELECT 1'); @@ -111,7 +293,7 @@ public function testQueryStreamReturnsReadableStreamWhenConnectionIsPending() $this->assertTrue($ret->isReadable()); } - public function testQueryStreamWillReturnStreamFromUnderlyingConnectionWhenResolved() + public function testQueryStreamWillReturnStreamFromUnderlyingConnectionWithoutStartingTimerWhenResolved() { $stream = new ThroughStream(); $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); @@ -119,7 +301,33 @@ public function testQueryStreamWillReturnStreamFromUnderlyingConnectionWhenResol $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); + + $ret = $connection->queryStream('SELECT 1'); + + $ret->on('data', $this->expectCallableOnceWith('hello')); + $stream->write('hello'); + + $this->assertTrue($ret->isReadable()); + } + + public function testQueryStreamWillReturnStreamFromUnderlyingConnectionAndStartTimerWhenResolvedAndClosed() + { + $stream = new ThroughStream(); + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('queryStream')->with('SELECT 1')->willReturn($stream); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); $ret = $connection->queryStream('SELECT 1'); @@ -137,7 +345,8 @@ public function testQueryStreamWillCloseStreamWithErrorWhenUnderlyingConnectionR $deferred = new Deferred(); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $ret = $connection->queryStream('SELECT 1'); @@ -154,7 +363,8 @@ public function testPingReturnsPendingPromiseWhenConnectionIsPending() $promise = new Promise(function () { }); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($promise); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $ret = $connection->ping(); @@ -165,11 +375,12 @@ public function testPingReturnsPendingPromiseWhenConnectionIsPending() public function testPingWillPingUnderlyingConnectionWhenResolved() { $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); - $base->expects($this->once())->method('ping'); + $base->expects($this->once())->method('ping')->willReturn(new Promise(function () { })); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->ping(); } @@ -181,7 +392,8 @@ public function testPingTwiceWillBothRejectWithSameErrorWhenUnderlyingConnection $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); @@ -195,17 +407,55 @@ public function testPingWillTryToCreateNewUnderlyingConnectionAfterPreviousPingF $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->exactly(2))->method('createConnection')->willReturn(\React\Promise\reject($error)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); $connection->ping()->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); } + public function testPingWillResolveAndStartTimerWhenPingFromUnderlyingConnectionResolves() + { + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); + + $ret = $connection->ping(); + $ret->then($this->expectCallableOnce(), $this->expectCallableNever()); + } + + public function testPingWillRejectAndStartTimerWhenPingFromUnderlyingConnectionRejects() + { + $error = new \RuntimeException(); + + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\reject($error)); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer'); + + $connection = new LazyConnection($factory, '', $loop); + + $ret = $connection->ping(); + $ret->then($this->expectCallableNever(), $this->expectCallableOnceWith($error)); + } + public function testQuitResolvesAndEmitsCloseImmediatelyWhenConnectionIsNotAlreadyPending() { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->never())->method('createConnection'); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableOnce()); @@ -221,7 +471,8 @@ public function testQuitAfterPingReturnsPendingPromiseWhenConnectionIsPending() $promise = new Promise(function () { }); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($promise); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->ping(); $ret = $connection->quit(); @@ -233,11 +484,13 @@ public function testQuitAfterPingReturnsPendingPromiseWhenConnectionIsPending() public function testQuitAfterPingWillQuitUnderlyingConnectionWhenResolved() { $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); - $base->expects($this->once())->method('quit'); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->ping(); $connection->quit(); @@ -251,7 +504,8 @@ public function testQuitAfterPingResolvesAndEmitsCloseWhenUnderlyingConnectionQu $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('close', $this->expectCallableOnce()); @@ -271,7 +525,8 @@ public function testQuitAfterPingRejectsAndEmitsCloseWhenUnderlyingConnectionFai $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('close', $this->expectCallableOnce()); @@ -286,7 +541,8 @@ public function testCloseEmitsCloseImmediatelyWhenConnectionIsNotAlreadyPending( { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->never())->method('createConnection'); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableOnce()); @@ -299,7 +555,8 @@ public function testCloseAfterPingCancelsPendingConnection() $deferred = new Deferred($this->expectCallableOnce()); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($deferred->promise()); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->ping(); $connection->close(); @@ -308,11 +565,13 @@ public function testCloseAfterPingCancelsPendingConnection() public function testCloseTwiceAfterPingWillCloseUnderlyingConnectionWhenResolved() { $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); $base->expects($this->once())->method('close'); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->ping(); $connection->close(); @@ -327,7 +586,8 @@ public function testCloseAfterPingDoesNotEmitConnectionErrorFromAbortedConnectio $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($promise); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableOnce()); @@ -336,12 +596,32 @@ public function testCloseAfterPingDoesNotEmitConnectionErrorFromAbortedConnectio $connection->close(); } + public function testCloseAfterPingWillCancelTimerWhenPingFromUnderlyingConnectionResolves() + { + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connection = new LazyConnection($factory, '', $loop); + + $connection->ping()->then($this->expectCallableOnce(), $this->expectCallableNever()); + $connection->close(); + } + public function testCloseTwiceAfterPingEmitsCloseEventOnceWhenConnectionIsPending() { $promise = new Promise(function () { }); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn($promise); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->on('error', $this->expectCallableNever()); $connection->on('close', $this->expectCallableOnce()); @@ -355,7 +635,8 @@ public function testQueryReturnsRejectedPromiseAfterConnectionIsClosed() { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->never())->method('createConnection'); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->close(); $ret = $connection->query('SELECT 1'); @@ -371,7 +652,8 @@ public function testQueryStreamThrowsAfterConnectionIsClosed() { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->never())->method('createConnection'); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->close(); $connection->queryStream('SELECT 1'); @@ -381,7 +663,8 @@ public function testPingReturnsRejectedPromiseAfterConnectionIsClosed() { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->never())->method('createConnection'); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->close(); $ret = $connection->ping(); @@ -394,7 +677,8 @@ public function testQuitReturnsRejectedPromiseAfterConnectionIsClosed() { $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->never())->method('createConnection'); - $connection = new LazyConnection($factory, ''); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); $connection->close(); $ret = $connection->quit(); From 472ae14e55f6cf7073be74fe6c582e18f87080ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 10 Nov 2018 15:45:14 +0100 Subject: [PATCH 3/3] Force-close previous (dis)connection when creating new connection --- src/Io/LazyConnection.php | 30 ++++++++- tests/Io/LazyConnectionTest.php | 110 +++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/Io/LazyConnection.php b/src/Io/LazyConnection.php index 5957d60..2dc35f2 100644 --- a/src/Io/LazyConnection.php +++ b/src/Io/LazyConnection.php @@ -21,6 +21,11 @@ class LazyConnection extends EventEmitter implements ConnectionInterface private $closed = false; private $busy = false; + /** + * @var ConnectionInterface|null + */ + private $disconnecting; + private $loop; private $idlePeriod = 60.0; private $idleTimer; @@ -45,6 +50,12 @@ private function connecting() return $this->connecting; } + // force-close connection if still waiting for previous disconnection + if ($this->disconnecting !== null) { + $this->disconnecting->close(); + $this->disconnecting = null; + } + $this->connecting = $connecting = $this->factory->createConnection($this->uri); $this->connecting->then(function (ConnectionInterface $connection) { // connection completed => remember only until closed @@ -81,7 +92,18 @@ private function idle() if ($this->pending < 1 && $this->idlePeriod >= 0) { $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () { $this->connecting->then(function (ConnectionInterface $connection) { - $connection->quit(); + $this->disconnecting = $connection; + $connection->quit()->then( + function () { + // successfully disconnected => remove reference + $this->disconnecting = null; + }, + function () use ($connection) { + // soft-close failed => force-close connection + $connection->close(); + $this->disconnecting = null; + } + ); }); $this->connecting = null; $this->idleTimer = null; @@ -184,6 +206,12 @@ public function close() $this->closed = true; + // force-close connection if still waiting for previous disconnection + if ($this->disconnecting !== null) { + $this->disconnecting->close(); + $this->disconnecting = null; + } + // either close active connection or cancel pending connection attempt if ($this->connecting !== null) { $this->connecting->then(function (ConnectionInterface $connection) { diff --git a/tests/Io/LazyConnectionTest.php b/tests/Io/LazyConnectionTest.php index 8f2b8e9..18e9c22 100644 --- a/tests/Io/LazyConnectionTest.php +++ b/tests/Io/LazyConnectionTest.php @@ -86,9 +86,10 @@ public function testPingWillNotForwardErrorFromUnderlyingConnection() public function testPingFollowedByIdleTimerWillQuitUnderlyingConnection() { - $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping', 'quit'))->disableOriginalConstructor()->getMock(); + $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping', 'quit', 'close'))->disableOriginalConstructor()->getMock(); $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); $base->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + $base->expects($this->never())->method('close'); $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); @@ -111,6 +112,68 @@ public function testPingFollowedByIdleTimerWillQuitUnderlyingConnection() $timeout(); } + public function testPingFollowedByIdleTimerWillCloseUnderlyingConnectionWhenQuitFails() + { + $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping', 'quit', 'close'))->disableOriginalConstructor()->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(\React\Promise\reject()); + $base->expects($this->once())->method('close'); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $connection = new LazyConnection($factory, '', $loop); + + $connection->on('close', $this->expectCallableNever()); + + $connection->ping(); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testPingAfterIdleTimerWillCloseUnderlyingConnectionBeforeCreatingSecondConnection() + { + $base = $this->getMockBuilder('React\MySQL\Io\LazyConnection')->setMethods(array('ping', 'quit', 'close'))->disableOriginalConstructor()->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $base->expects($this->once())->method('close'); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->exactly(2))->method('createConnection')->willReturnOnConsecutiveCalls( + \React\Promise\resolve($base), + new Promise(function () { }) + ); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $connection = new LazyConnection($factory, '', $loop); + + $connection->on('close', $this->expectCallableNever()); + + $connection->ping(); + + $this->assertNotNull($timeout); + $timeout(); + + $connection->ping(); + } + + public function testQueryReturnsPendingPromiseAndWillNotStartTimerWhenConnectionIsPending() { $deferred = new Deferred(); @@ -615,6 +678,51 @@ public function testCloseAfterPingWillCancelTimerWhenPingFromUnderlyingConnectio $connection->close(); } + public function testCloseAfterQuitAfterPingWillCloseUnderlyingConnectionWhenQuitIsStillPending() + { + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $base->expects($this->once())->method('close'); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connection = new LazyConnection($factory, '', $loop); + + $connection->ping(); + $connection->quit(); + $connection->close(); + } + + public function testCloseAfterPingAfterIdleTimeoutWillCloseUnderlyingConnectionWhenQuitIsStillPending() + { + $base = $this->getMockBuilder('React\MySQL\ConnectionInterface')->getMock(); + $base->expects($this->once())->method('ping')->willReturn(\React\Promise\resolve()); + $base->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $base->expects($this->once())->method('close'); + + $factory = $this->getMockBuilder('React\MySQL\Factory')->disableOriginalConstructor()->getMock(); + $factory->expects($this->once())->method('createConnection')->willReturn(\React\Promise\resolve($base)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $connection = new LazyConnection($factory, '', $loop); + + $connection->ping(); + + $this->assertNotNull($timeout); + $timeout(); + + $connection->close(); + } + public function testCloseTwiceAfterPingEmitsCloseEventOnceWhenConnectionIsPending() { $promise = new Promise(function () { });