Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
26 changes: 25 additions & 1 deletion src/Database/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

/**
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/Support/swoole-absent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

/**
* Exercises the lifecycle-hook paths that reach Database::getEventContext(), plus
* Connection::hasError(), in a process where ext-swoole is unavailable.
*
* Run by SwooleAbsentTest through a subprocess started with -n, because an
* extension cannot be unloaded from inside a running interpreter.
*/

require dirname(__DIR__, 3) . '/vendor/autoload.php';

use Utopia\Cache\Adapter\None;
use Utopia\Cache\Cache;
use Utopia\Database\Adapter\Memory;
use Utopia\Database\Collection;
use Utopia\Database\Connection;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;

echo 'swoole=' . (extension_loaded('swoole') ? '1' : '0') . PHP_EOL;

$database = new Database(new Memory(), new Cache(new None()));
$database->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;
72 changes: 72 additions & 0 deletions tests/unit/SwooleAbsentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class SwooleAbsentTest extends TestCase
{
/**
* @param array<string> $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);
}
}
Loading