diff --git a/composer.json b/composer.json index 147da1ecd..1597bba01 100755 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ }, "suggest": { "ext-redis": "Needed to support Redis Cache Adapter", + "ext-swoole": "Needed to scope lifecycle hook silencing per coroutine and to detect lost connections", "ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter", "mongodb/mongodb": "Needed to support MongoDB Database Adapter" }, diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 024aecc26..3d58f2c3f 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -15,6 +15,30 @@ class Connection */ protected static array $errors = [ 'Max connect timeout reached', + 'server has gone away', + 'no connection to the server', + 'Lost connection', + 'is dead or not enabled', + 'Error while sending', + 'decryption failed or bad record mac', + 'server closed the connection unexpectedly', + 'SSL connection has been closed unexpectedly', + 'Error writing data to the connection', + 'Resource deadlock avoided', + 'Transaction() on null', + 'child connection forced to terminate due to client_idle_limit', + 'query_wait_timeout', + 'reset by peer', + 'Physical connection is not usable', + 'TCP Provider: Error code 0x68', + 'ORA-03114', + 'Packets out of order. Expected', + 'Adaptive Server connection failed', + 'Communication link failure', + 'connection is no longer usable', + 'Login timeout expired', + 'running with the --read-only option so it cannot execute this statement', + 'SQLSTATE[HY000] [2002] Connection refused', ]; /** @@ -25,7 +49,7 @@ class Connection */ public static function hasError(Throwable $e): bool { - if (DetectsLostConnections::causedByLostConnection($e)) { + if (\class_exists(DetectsLostConnections::class) && DetectsLostConnections::causedByLostConnection($e)) { return true; } diff --git a/src/Database/Database.php b/src/Database/Database.php index b3c4aec3b..99e8e1a1a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1460,6 +1460,10 @@ protected function areEventsSilenced(): bool private function getEventContext(): int { + if (! \extension_loaded('swoole')) { + return -1; + } + $context = Coroutine::getCid(); return \is_int($context) ? $context : -1; diff --git a/tests/unit/Support/swoole-absent.php b/tests/unit/Support/swoole-absent.php new file mode 100644 index 000000000..05814cc14 --- /dev/null +++ b/tests/unit/Support/swoole-absent.php @@ -0,0 +1,65 @@ +setDatabase('utopiaTests')->setNamespace('swoole_absent'); +$database->create(); +$database->getAuthorization()->addRole(Role::any()->toString()); + +echo 'create=ok' . PHP_EOL; + +echo 'silent=' . $database->silent(fn () => 'ok') . PHP_EOL; + +$database->createCollection(new Collection( + id: 'logs', + permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::delete(Role::any()), + ], + documentSecurity: false, +)); + +foreach (['a', 'b', 'c'] as $id) { + $database->createDocument('logs', new Document(['$id' => $id])); +} + +$deleted = $database->deleteDocuments('logs', [Query::limit(10)]); + +echo 'deleted=' . $deleted . PHP_EOL; +echo 'remaining=' . \count($database->find('logs', [Query::limit(10)])) . PHP_EOL; +$lost = 0; +foreach ([ + 'SQLSTATE[HY000]: General error: 2006 MySQL server has gone away', + 'Lost connection to MySQL server during query', + 'SQLSTATE[08006] server closed the connection unexpectedly', + 'Max connect timeout reached', + 'Communication link failure', +] as $message) { + $lost += Connection::hasError(new RuntimeException($message)) ? 1 : 0; +} + +echo 'lostDetected=' . $lost . PHP_EOL; +echo 'unrelatedDetected=' . (Connection::hasError(new RuntimeException('syntax error near FROM')) ? '1' : '0') . PHP_EOL; diff --git a/tests/unit/SwooleAbsentTest.php b/tests/unit/SwooleAbsentTest.php new file mode 100644 index 000000000..70b34ca47 --- /dev/null +++ b/tests/unit/SwooleAbsentTest.php @@ -0,0 +1,72 @@ + $flags + * @return array{status: int, output: string} + */ + private function runFixture(array $flags): array + { + $command = \escapeshellarg(PHP_BINARY); + + foreach ($flags as $flag) { + $command .= ' ' . $flag; + } + + $command .= ' ' . \escapeshellarg(__DIR__ . '/Support/swoole-absent.php') . ' 2>&1'; + + \exec($command, $lines, $status); + + return ['status' => $status, 'output' => \implode(PHP_EOL, $lines)]; + } + + /** + * ext-swoole is optional: it is absent from composer.json's require block, so a + * consumer may run the library on a PHP that does not have it. An extension cannot + * be unloaded from a running interpreter, so this drives a subprocess started with + * -n, which skips php.ini and every conf.d file and therefore loads no shared + * extension. Swoole ships as a shared extension in the test image and in every + * environment that installs it through pecl. + */ + public function testDatabaseOperatesWithoutSwoole(): void + { + ['status' => $status, 'output' => $output] = $this->runFixture(['-n']); + + if (\str_contains($output, 'swoole=1')) { + $this->markTestSkipped('swoole is statically compiled into ' . PHP_BINARY . ', so its absence cannot be exercised'); + } + + $this->assertSame(0, $status, "Fixture exited {$status} without swoole:" . PHP_EOL . $output); + + $this->assertStringContainsString('create=ok', $output, $output); + $this->assertStringContainsString('silent=ok', $output, $output); + $this->assertStringContainsString('deleted=3', $output, $output); + $this->assertStringContainsString('remaining=0', $output, $output); + $this->assertStringContainsString('lostDetected=5', $output, $output); + $this->assertStringContainsString('unrelatedDetected=0', $output, $output); + } + + /** + * Swoole\Database\DetectsLostConnections comes from Swoole's PHP-land library, which + * swoole.enable_library=Off switches off while leaving the extension loaded. Lost + * connections must still be recognised, so detection cannot rest on that class. + */ + public function testLostConnectionsAreDetectedWithoutSwooleLibrary(): void + { + if (! \extension_loaded('swoole')) { + $this->markTestSkipped('swoole is not loaded, so its library cannot be switched off'); + } + + ['status' => $status, 'output' => $output] = $this->runFixture(['-d swoole.enable_library=Off']); + + $this->assertSame(0, $status, "Fixture exited {$status} with the swoole library disabled:" . PHP_EOL . $output); + + $this->assertStringContainsString('lostDetected=5', $output, $output); + $this->assertStringContainsString('unrelatedDetected=0', $output, $output); + } +}