From 5d581897082694155fce614cd50efa34316ef5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 10 May 2019 10:44:17 +0200 Subject: [PATCH 1/2] Support lazy connections which connect on demand with idle timeout Add new loadLazy() method to connect only on demand and implement "idle" timeout to close underlying connection when unused. Builds on top of https://github.com/clue/reactphp-redis/pull/87 and https://github.com/friends-of-reactphp/mysql/pull/87 and others. --- README.md | 94 ++- examples/insert.php | 20 +- examples/search.php | 18 +- src/Factory.php | 83 ++- src/Io/LazyDatabase.php | 174 +++++ tests/FactoryTest.php | 31 + tests/Io/LazyDatabaseTest.php | 666 ++++++++++++++++++ ...baseTest.php => ProcessIoDatabaseTest.php} | 2 +- 8 files changed, 1050 insertions(+), 38 deletions(-) create mode 100644 src/Io/LazyDatabase.php create mode 100644 tests/FactoryTest.php create mode 100644 tests/Io/LazyDatabaseTest.php rename tests/Io/{DatabaseTest.php => ProcessIoDatabaseTest.php} (99%) diff --git a/README.md b/README.md index 11fb6d8..cd413f6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ built on top of [ReactPHP](https://reactphp.org/). * [Usage](#usage) * [Factory](#factory) * [open()](#open) + * [openLazy()](#openlazy) * [DatabaseInterface](#databaseinterface) * [exec()](#exec) * [query()](#query) @@ -31,24 +32,18 @@ existing SQLite database file (or automatically create it on first run) and then $loop = React\EventLoop\Factory::create(); $factory = new Clue\React\SQLite\Factory($loop); +$db = $factory->openLazy('users.db'); +$db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); + $name = 'Alice'; -$factory->open('users.db')->then( - function (Clue\React\SQLite\DatabaseInterface $db) use ($name) { - $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); - - $db->query('INSERT INTO foo (bar) VALUES (?)', array($name))->then( - function (Clue\React\SQLite\Result $result) use ($name) { - echo 'New ID for ' . $name . ': ' . $result->insertId . PHP_EOL; - } - ); - - $db->quit(); - }, - function (Exception $e) { - echo 'Error: ' . $e->getMessage() . PHP_EOL; +$db->query('INSERT INTO foo (bar) VALUES (?)', [$name])->then( + function (Clue\React\SQLite\Result $result) use ($name) { + echo 'New ID for ' . $name . ': ' . $result->insertId . PHP_EOL; } ); +$db->quit(); + $loop->run(); ``` @@ -101,6 +96,75 @@ $factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (DatabaseInterf }); ``` +#### openLazy() + +The `openLazy(string $filename, int $flags = null, array $options = []): DatabaseInterface` method can be used to +open a new database connection for the given SQLite database file. + +```php +$db = $factory->openLazy('users.db'); + +$db->query('INSERT INTO users (name) VALUES ("test")'); +$db->quit(); +``` + +This method immediately returns a "virtual" connection implementing the +[`DatabaseInterface`](#databaseinterface) that can be used to +interface with your SQLite database. Internally, it lazily creates the +underlying database process only on demand once the first request is +invoked on this instance and will queue all outstanding requests until +the underlying database is ready. Additionally, it will only keep this +underlying database in an "idle" state for 60s by default and will +automatically end the underlying database 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 database process may still be +outstanding. Because creating this underlying process may take some +time, it will enqueue all oustanding commands and will ensure that all +commands will be executed in correct order once the database is ready. +In other words, this "virtual" database behaves just like a "real" +database as described in the `DatabaseInterface` and frees you from +having to deal with its async resolution. + +If the underlying database process 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 a new underlying database. 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 database 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 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 `open()` method which resolves with a promise. For many +simple use cases it may be easier to create a lazy connection. + +The optional `$flags` parameter is used to determine how to open the +SQLite database. By default, open uses `SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE`. + +```php +$db = $factory->openLazy('users.db', SQLITE3_OPEN_READONLY); +``` + +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 +$db = $factory->openLazy('users.db', null, ['idle' => 0.1]); +``` + ### DatabaseInterface The `DatabaseInterface` represents a connection that is responsible for @@ -149,7 +213,7 @@ method instead. #### query() -The `query(string $query, array $params = array()): PromiseInterface` method can be used to +The `query(string $query, array $params = []): PromiseInterface` method can be used to perform an async query. diff --git a/examples/insert.php b/examples/insert.php index 706a173..c8b01b6 100644 --- a/examples/insert.php +++ b/examples/insert.php @@ -1,6 +1,5 @@ open('test.db')->then(function (DatabaseInterface $db) use ($n) { - $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); +$db = $factory->openLazy('test.db'); - for ($i = 0; $i < $n; ++$i) { - $db->exec("INSERT INTO foo (bar) VALUES ('This is a test')")->then(function (Result $result) { - echo 'New row ' . $result->insertId . PHP_EOL; - }); - } +$promise = $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); +$promise->then(null, 'printf'); - $db->quit(); -}, 'printf'); +for ($i = 0; $i < $n; ++$i) { + $db->exec("INSERT INTO foo (bar) VALUES ('This is a test')")->then(function (Result $result) { + echo 'New row ' . $result->insertId . PHP_EOL; + }); +} + +$db->quit(); $loop->run(); diff --git a/examples/search.php b/examples/search.php index e8a7a97..39466a0 100644 --- a/examples/search.php +++ b/examples/search.php @@ -10,15 +10,15 @@ $factory = new Factory($loop); $search = isset($argv[1]) ? $argv[1] : 'foo'; -$factory->open('test.db')->then(function (DatabaseInterface $db) use ($search){ - $db->query('SELECT * FROM foo WHERE bar LIKE ?', ['%' . $search . '%'])->then(function (Result $result) { - echo 'Found ' . count($result->rows) . ' rows: ' . PHP_EOL; - echo implode("\t", $result->columns) . PHP_EOL; - foreach ($result->rows as $row) { - echo implode("\t", $row) . PHP_EOL; - } - }, 'printf'); - $db->quit(); +$db = $factory->openLazy('test.db'); + +$db->query('SELECT * FROM foo WHERE bar LIKE ?', ['%' . $search . '%'])->then(function (Result $result) { + echo 'Found ' . count($result->rows) . ' rows: ' . PHP_EOL; + echo implode("\t", $result->columns) . PHP_EOL; + foreach ($result->rows as $row) { + echo implode("\t", $row) . PHP_EOL; + } }, 'printf'); +$db->quit(); $loop->run(); diff --git a/src/Factory.php b/src/Factory.php index d888fb6..ad5c7f8 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -2,12 +2,12 @@ namespace Clue\React\SQLite; +use Clue\React\SQLite\Io\LazyDatabase; +use Clue\React\SQLite\Io\ProcessIoDatabase; use React\ChildProcess\Process; use React\EventLoop\LoopInterface; -use Clue\React\SQLite\Io\ProcessIoDatabase; -use React\Stream\DuplexResourceStream; use React\Promise\Deferred; -use React\Stream\ThroughStream; +use React\Stream\DuplexResourceStream; class Factory { @@ -88,6 +88,83 @@ public function open($filename, $flags = null) return $this->useSocket ? $this->openSocketIo($filename, $flags) : $this->openProcessIo($filename, $flags); } + /** + * Opens a new database connection for the given SQLite database file. + * + * ```php + * $db = $factory->openLazy('users.db'); + * + * $db->query('INSERT INTO users (name) VALUES ("test")'); + * $db->quit(); + * ``` + * + * This method immediately returns a "virtual" connection implementing the + * [`DatabaseInterface`](#databaseinterface) that can be used to + * interface with your SQLite database. Internally, it lazily creates the + * underlying database process only on demand once the first request is + * invoked on this instance and will queue all outstanding requests until + * the underlying database is ready. Additionally, it will only keep this + * underlying database in an "idle" state for 60s by default and will + * automatically end the underlying database 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 database process may still be + * outstanding. Because creating this underlying process may take some + * time, it will enqueue all oustanding commands and will ensure that all + * commands will be executed in correct order once the database is ready. + * In other words, this "virtual" database behaves just like a "real" + * database as described in the `DatabaseInterface` and frees you from + * having to deal with its async resolution. + * + * If the underlying database process 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 a new underlying database. 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 database 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 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 `open()` method which resolves with a promise. For many + * simple use cases it may be easier to create a lazy connection. + * + * The optional `$flags` parameter is used to determine how to open the + * SQLite database. By default, open uses `SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE`. + * + * ```php + * $db = $factory->openLazy('users.db', SQLITE3_OPEN_READONLY); + * ``` + * + * 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 + * $db = $factory->openLazy('users.db', null, ['idle' => 0.1]); + * ``` + * + * @param string $filename + * @param ?int $flags + * @param array $options + * @return DatabaseInterface + */ + public function openLazy($filename, $flags = null, array $options = []) + { + return new LazyDatabase($filename, $flags, $options, $this, $this->loop); + } + private function openProcessIo($filename, $flags = null) { $command = 'exec ' . \escapeshellarg($this->bin) . ' sqlite-worker.php'; diff --git a/src/Io/LazyDatabase.php b/src/Io/LazyDatabase.php new file mode 100644 index 0000000..a5c8698 --- /dev/null +++ b/src/Io/LazyDatabase.php @@ -0,0 +1,174 @@ +filename = $target; + $this->flags = $flags; + $this->factory = $factory; + $this->loop = $loop; + + if (isset($options['idle'])) { + $this->idlePeriod = (float)$options['idle']; + } + } + + /** + * @return \React\Promise\PromiseInterface + */ + private function db() + { + if ($this->promise !== null) { + return $this->promise; + } + + if ($this->closed) { + return \React\Promise\reject(new \RuntimeException('Connection closed')); + } + + $this->promise = $promise = $this->factory->open($this->filename, $this->flags); + $promise->then(function (DatabaseInterface $db) { + // connection completed => remember only until closed + $db->on('close', function () { + $this->promise = null; + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + }); + }, function () { + // connection failed => discard connection attempt + $this->promise = null; + }); + + return $promise; + } + + public function exec($sql) + { + return $this->db()->then(function (DatabaseInterface $db) use ($sql) { + $this->awake(); + return $db->exec($sql)->then( + function ($result) { + $this->idle(); + return $result; + }, + function ($error) { + $this->idle(); + throw $error; + } + ); + }); + } + + public function query($sql, array $params = []) + { + return $this->db()->then(function (DatabaseInterface $db) use ($sql, $params) { + $this->awake(); + return $db->query($sql, $params)->then( + function ($result) { + $this->idle(); + return $result; + }, + function ($error) { + $this->idle(); + throw $error; + } + ); + }); + } + + public function quit() + { + if ($this->promise === null && !$this->closed) { + $this->close(); + return \React\Promise\resolve(); + } + + return $this->db()->then(function (DatabaseInterface $db) { + $db->on('close', function () { + $this->close(); + }); + return $db->quit(); + }); + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + // either close active connection or cancel pending connection attempt + if ($this->promise !== null) { + $this->promise->then(function (DatabaseInterface $db) { + $db->close(); + }); + if ($this->promise !== null) { + $this->promise->cancel(); + $this->promise = null; + } + } + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + + $this->emit('close'); + $this->removeAllListeners(); + } + + 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->promise->then(function (DatabaseInterface $db) { + $db->close(); + }); + $this->promise = null; + $this->idleTimer = null; + }); + } + } +} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php new file mode 100644 index 0000000..66fa5e2 --- /dev/null +++ b/tests/FactoryTest.php @@ -0,0 +1,31 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $factory = new Factory($loop); + + $db = $factory->openLazy(':memory:'); + + $this->assertInstanceOf('Clue\React\SQLite\DatabaseInterface', $db); + } + + public function testLoadLazyWithIdleOptionsReturnsDatabaseWithIdleTimeApplied() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $factory = new Factory($loop); + + $db = $factory->openLazy(':memory:', null, ['idle' => 10.0]); + + $ref = new ReflectionProperty($db, 'idlePeriod'); + $ref->setAccessible(true); + $value = $ref->getValue($db); + + $this->assertEquals(10.0, $value); + } +} diff --git a/tests/Io/LazyDatabaseTest.php b/tests/Io/LazyDatabaseTest.php new file mode 100644 index 0000000..250015b --- /dev/null +++ b/tests/Io/LazyDatabaseTest.php @@ -0,0 +1,666 @@ +factory = $this->getMockBuilder('Clue\React\SQLite\Factory')->disableOriginalConstructor()->getMock(); + $this->loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $this->db = new LazyDatabase('localhost', null, [], $this->factory, $this->loop); + } + + public function testExecWillCreateUnderlyingDatabaseAndReturnPendingPromise() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + + $promise->then($this->expectCallableNever()); + } + + public function testExecTwiceWillCreateOnceUnderlyingDatabase() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->exec('CREATE'); + $this->db->exec('CREATE'); + } + + public function testExecWillRejectWhenCreateUnderlyingDatabaseRejects() + { + $ex = new \RuntimeException(); + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\reject($ex)); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $promise->then(null, $this->expectCallableOnceWith($ex)); + } + + public function testExecAgainAfterPreviousExecRejectedBecauseCreateUnderlyingDatabaseRejectsWillTryToOpenDatabaseAgain() + { + $ex = new \RuntimeException(); + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\reject($ex), + new Promise(function () { }) + ); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $promise->then(null, $this->expectCallableOnceWith($ex)); + + $this->db->exec('CREATE'); + } + + public function testExecWillResolveWhenUnderlyingDatabaseResolvesExecAndStartIdleTimer() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer')->with(60, $this->anything()); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testExecWillResolveWhenUnderlyingDatabaseResolvesExecAndStartIdleTimerWithIdleTimeFromOptions() + { + $this->db = new LazyDatabase(':memory:', null, ['idle' => 10.0], $this->factory, $this->loop); + + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer')->with(10.0, $this->anything()); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testExecWillResolveWhenUnderlyingDatabaseResolvesExecAndNotStartIdleTimerWhenIdleOptionIsNegative() + { + $this->db = new LazyDatabase(':memory:', null, ['idle' => -1], $this->factory, $this->loop); + + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testExecWillRejectWhenUnderlyingDatabaseRejectsExecAndStartIdleTimer() + { + $error = new \RuntimeException(); + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\reject($error)); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testExecWillRejectAndNotEmitErrorOrCloseWhenFactoryRejectsUnderlyingDatabase() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->on('error', $this->expectCallableNever()); + $this->db->on('close', $this->expectCallableNever()); + + $promise = $this->db->exec('CREATE'); + $deferred->reject($error); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testExecAfterPreviousFactoryRejectsUnderlyingDatabaseWillCreateNewUnderlyingConnection() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->db->exec('CREATE'); + $deferred->reject($error); + + $this->db->exec('CREATE'); + } + + public function testExecAfterPreviousUnderlyingDatabaseAlreadyClosedWillCreateNewUnderlyingConnection() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve('PONG')); + + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\resolve($client), + new Promise(function () { }) + ); + + $this->db->exec('CREATE'); + $client->emit('close'); + + $this->db->exec('CREATE'); + } + + public function testExecAfterCloseWillRejectWithoutCreatingUnderlyingConnection() + { + $this->factory->expects($this->never())->method('open'); + + $this->db->close(); + $promise = $this->db->exec('CREATE'); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testExecAfterExecWillNotStartIdleTimerWhenFirstExecResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->exactly(2))->method('exec')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $this->loop->expects($this->never())->method('addTimer'); + + $this->db->exec('CREATE'); + $this->db->exec('CREATE'); + $deferred->resolve(); + } + + public function testExecAfterExecWillStartAndCancelIdleTimerWhenSecondExecStartsAfterFirstResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->exactly(2))->method('exec')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->exec('CREATE'); + $deferred->resolve(); + $this->db->exec('CREATE'); + } + + public function testExecFollowedByIdleTimerWillCloseUnderlyingConnectionWithoutCloseEvent() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'close'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('close')->willReturn(\React\Promise\resolve()); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testQueryWillCreateUnderlyingDatabaseAndReturnPendingPromise() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->query('CREATE'); + + $promise->then($this->expectCallableNever()); + } + + public function testQueryTwiceWillCreateOnceUnderlyingDatabase() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->query('CREATE'); + $this->db->query('CREATE'); + } + + public function testQueryWillResolveWhenUnderlyingDatabaseResolvesQueryAndStartIdleTimer() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('query')->with('SELECT :id', ['id' => 42])->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer')->with(60.0, $this->anything()); + + $promise = $this->db->query('SELECT :id', ['id' => 42]); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testQueryWillRejectWhenUnderlyingDatabaseRejectsQueryAndStartIdleTimer() + { + $error = new \RuntimeException(); + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('query')->with('CREATE')->willReturn(\React\Promise\reject($error)); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer'); + + $promise = $this->db->query('CREATE'); + $deferred->resolve($client); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testQueryWillRejectAndNotEmitErrorOrCloseWhenFactoryRejectsUnderlyingDatabase() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->on('error', $this->expectCallableNever()); + $this->db->on('close', $this->expectCallableNever()); + + $promise = $this->db->query('CREATE'); + $deferred->reject($error); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testQueryAfterPreviousFactoryRejectsUnderlyingDatabaseWillCreateNewUnderlyingConnection() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->db->query('CREATE'); + $deferred->reject($error); + + $this->db->query('CREATE'); + } + + public function testQueryAfterPreviousUnderlyingDatabaseAlreadyClosedWillCreateNewUnderlyingConnection() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query'))->getMock(); + $client->expects($this->once())->method('query')->willReturn(\React\Promise\resolve('PONG')); + + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\resolve($client), + new Promise(function () { }) + ); + + $this->db->query('CREATE'); + $client->emit('close'); + + $this->db->query('CREATE'); + } + + public function testQueryAfterCloseWillRejectWithoutCreatingUnderlyingConnection() + { + $this->factory->expects($this->never())->method('open'); + + $this->db->close(); + $promise = $this->db->query('CREATE'); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryAfterQueryWillNotStartIdleTimerWhenFirstQueryResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query'))->getMock(); + $client->expects($this->exactly(2))->method('query')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $this->loop->expects($this->never())->method('addTimer'); + + $this->db->query('CREATE'); + $this->db->query('CREATE'); + $deferred->resolve(); + } + + public function testQueryAfterQueryWillStartAndCancelIdleTimerWhenSecondQueryStartsAfterFirstResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query'))->getMock(); + $client->expects($this->exactly(2))->method('query')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->query('CREATE'); + $deferred->resolve(); + $this->db->query('CREATE'); + } + + public function testQueryFollowedByIdleTimerWillCloseUnderlyingConnectionWithoutCloseEvent() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query', 'close'))->getMock(); + $client->expects($this->once())->method('query')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('close')->willReturn(\React\Promise\resolve()); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->query('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testCloseWillEmitCloseEventWithoutCreatingUnderlyingDatabase() + { + $this->factory->expects($this->never())->method('open'); + + $this->db->on('close', $this->expectCallableOnce()); + + $this->db->close(); + } + + public function testCloseTwiceWillEmitCloseEventOnce() + { + $this->db->on('close', $this->expectCallableOnce()); + + $this->db->close(); + $this->db->close(); + } + + public function testCloseAfterExecWillCancelUnderlyingDatabaseConnectionWhenStillPending() + { + $promise = new Promise(function () { }, $this->expectCallableOnce()); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->exec('CREATE'); + $this->db->close(); + } + + public function testCloseAfterExecWillEmitCloseWithoutErrorWhenUnderlyingDatabaseConnectionThrowsDueToCancellation() + { + $promise = new Promise(function () { }, function () { + throw new \RuntimeException('Discarded'); + }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->on('error', $this->expectCallableNever()); + $this->db->on('close', $this->expectCallableOnce()); + + $this->db->exec('CREATE'); + $this->db->close(); + } + + public function testCloseAfterExecWillCloseUnderlyingDatabaseConnectionWhenAlreadyResolved() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('close'); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + $this->db->close(); + } + + public function testCloseAfterExecWillCancelIdleTimerWhenExecIsAlreadyResolved() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'close'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn($deferred->promise()); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->exec('CREATE'); + $deferred->resolve(); + $this->db->close(); + } + + public function testCloseAfterExecRejectsWillEmitClose() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'close'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn($deferred->promise()); + $client->expects($this->once())->method('close')->willReturnCallback(function () use ($client) { + $client->emit('close'); + }); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $ref = $this->db; + $ref->exec('CREATE')->then(null, function () use ($ref, $client) { + $ref->close(); + }); + $ref->on('close', $this->expectCallableOnce()); + $deferred->reject(new \RuntimeException()); + } + + public function testQuitWillCloseDatabaseIfUnderlyingConnectionIsNotPendingAndResolveImmediately() + { + $this->db->on('close', $this->expectCallableOnce()); + $promise = $this->db->quit(); + + $promise->then($this->expectCallableOnce()); + } + + public function testQuitAfterQuitWillReject() + { + $this->db->quit(); + $promise = $this->db->quit(); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQuitAfterExecWillQuitUnderlyingDatabase() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve('PONG')); + $client->expects($this->once())->method('quit'); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + $promise = $this->db->quit(); + + $promise->then($this->expectCallableOnce()); + } + + public function testQuitAfterExecWillCloseDatabaseWhenUnderlyingDatabaseEmitsClose() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'quit'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve('PONG')); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + + $this->db->on('close', $this->expectCallableOnce()); + $promise = $this->db->quit(); + + $client->emit('close'); + $promise->then($this->expectCallableOnce()); + } + + public function testEmitsNoErrorEventWhenUnderlyingDatabaseEmitsError() + { + $error = new \RuntimeException(); + + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + + $this->db->on('error', $this->expectCallableNever()); + $client->emit('error', array($error)); + } + + public function testEmitsNoCloseEventWhenUnderlyingDatabaseEmitsClose() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + + $this->db->on('close', $this->expectCallableNever()); + $client->emit('close'); + } + + public function testEmitsNoCloseEventButWillCancelIdleTimerWhenUnderlyingConnectionEmitsCloseAfterExecIsAlreadyResolved() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn($deferred->promise()); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + $deferred->resolve(); + + $client->emit('close'); + } + + protected function expectCallableNever() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableOnce() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableOnceWith($value) + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($value); + + return $mock; + } + + protected function createCallableMock() + { + return $this->getMockBuilder('stdClass')->setMethods(array('__invoke'))->getMock(); + } +} diff --git a/tests/Io/DatabaseTest.php b/tests/Io/ProcessIoDatabaseTest.php similarity index 99% rename from tests/Io/DatabaseTest.php rename to tests/Io/ProcessIoDatabaseTest.php index a8c93ad..c5a57e9 100644 --- a/tests/Io/DatabaseTest.php +++ b/tests/Io/ProcessIoDatabaseTest.php @@ -4,7 +4,7 @@ use Clue\React\SQLite\DatabaseInterface; use React\Stream\ThroughStream; -class DatabaseTest extends TestCase +class ProcessIoDatabaseTest extends TestCase { public function testDatabaseWillEmitErrorWhenStdoutReportsNonNdjsonStream() { From 0c54e19e1a0930eaea3b5bafc55471b064a9cb91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 10 May 2019 14:00:26 +0200 Subject: [PATCH 2/2] Force-close previous (dis)connection when opening new database --- src/Io/LazyDatabase.php | 27 ++++++++- tests/Io/LazyDatabaseTest.php | 105 ++++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/Io/LazyDatabase.php b/src/Io/LazyDatabase.php index a5c8698..7beb1fe 100644 --- a/src/Io/LazyDatabase.php +++ b/src/Io/LazyDatabase.php @@ -19,6 +19,8 @@ class LazyDatabase extends EventEmitter implements DatabaseInterface private $loop; private $closed = false; + /**@var ?DatabaseInterface */ + private $disconnecting; /** @var ?\React\Promise\PromiseInterface */ private $promise; private $idlePeriod = 60.0; @@ -51,6 +53,12 @@ private function db() return \React\Promise\reject(new \RuntimeException('Connection closed')); } + // force-close connection if still waiting for previous disconnection + if ($this->disconnecting !== null) { + $this->disconnecting->close(); + $this->disconnecting = null; + } + $this->promise = $promise = $this->factory->open($this->filename, $this->flags); $promise->then(function (DatabaseInterface $db) { // connection completed => remember only until closed @@ -127,6 +135,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->promise !== null) { $this->promise->then(function (DatabaseInterface $db) { @@ -164,7 +178,18 @@ private function idle() if ($this->pending < 1 && $this->idlePeriod >= 0) { $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () { $this->promise->then(function (DatabaseInterface $db) { - $db->close(); + $this->disconnecting = $db; + $db->quit()->then( + function () { + // successfully disconnected => remove reference + $this->disconnecting = null; + }, + function () use ($db) { + // soft-close failed => force-close connection + $db->close(); + $this->disconnecting = null; + } + ); }); $this->promise = null; $this->idleTimer = null; diff --git a/tests/Io/LazyDatabaseTest.php b/tests/Io/LazyDatabaseTest.php index 250015b..24e6b7a 100644 --- a/tests/Io/LazyDatabaseTest.php +++ b/tests/Io/LazyDatabaseTest.php @@ -234,11 +234,36 @@ public function testExecAfterExecWillStartAndCancelIdleTimerWhenSecondExecStarts $this->db->exec('CREATE'); } - public function testExecFollowedByIdleTimerWillCloseUnderlyingConnectionWithoutCloseEvent() + public function testExecFollowedByIdleTimerWillQuitUnderlyingConnectionWithoutCloseEvent() { - $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'close'))->getMock(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'quit', 'close'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + $client->expects($this->never())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testExecFollowedByIdleTimerWillCloseUnderlyingConnectionWhenQuitFails() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->setMethods(array('exec', 'quit', 'close'))->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); - $client->expects($this->once())->method('close')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\reject()); + $client->expects($this->once())->method('close'); $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); @@ -257,6 +282,35 @@ public function testExecFollowedByIdleTimerWillCloseUnderlyingConnectionWithoutC $timeout(); } + public function testExecAfterIdleTimerWillCloseUnderlyingConnectionBeforeCreatingSecondConnection() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->setMethods(array('exec', 'quit', 'close'))->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\resolve($client), + new Promise(function () { }) + ); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + + $this->db->exec('CREATE'); + } + public function testQueryWillCreateUnderlyingDatabaseAndReturnPendingPromise() { $promise = new Promise(function () { }); @@ -407,11 +461,12 @@ public function testQueryAfterQueryWillStartAndCancelIdleTimerWhenSecondQuerySta $this->db->query('CREATE'); } - public function testQueryFollowedByIdleTimerWillCloseUnderlyingConnectionWithoutCloseEvent() + public function testQueryFollowedByIdleTimerWillQuitUnderlyingConnectionWithoutCloseEvent() { - $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query', 'close'))->getMock(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query', 'quit', 'close'))->getMock(); $client->expects($this->once())->method('query')->willReturn(\React\Promise\resolve()); - $client->expects($this->once())->method('close')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + $client->expects($this->never())->method('close'); $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); @@ -525,6 +580,44 @@ public function testCloseAfterExecRejectsWillEmitClose() $deferred->reject(new \RuntimeException()); } + public function testCloseAfterQuitAfterExecWillCloseUnderlyingConnectionWhenQuitIsStillPending() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $this->db->exec('CREATE'); + $this->db->quit(); + $this->db->close(); + } + + public function testCloseAfterExecAfterIdleTimeoutWillCloseUnderlyingConnectionWhenQuitIsStillPending() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + + $this->db->close(); + } + public function testQuitWillCloseDatabaseIfUnderlyingConnectionIsNotPendingAndResolveImmediately() { $this->db->on('close', $this->expectCallableOnce());