diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 937cb26..4a17bfa 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -26,7 +26,9 @@ jobs: docker-compose exec -T app composer install -n - name: Run Unit Tests - run: docker-compose exec -T app composer test + run: | + sleep 40s + docker-compose exec -T app composer test - name: Stop and Remove Docker Containers run: docker-compose down diff --git a/Dockerfile b/Dockerfile index a721a2b..14d1791 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,45 @@ FROM phpswoole/swoole -RUN pecl update-channels -RUN docker-php-ext-enable redis -RUN docker-php-ext-install mysqli -RUN docker-php-ext-enable mysqli -RUN docker-php-ext-install pdo_mysql -RUN docker-php-ext-enable pdo_mysql - -RUN echo "swoole.enable_library=off" >> /usr/local/etc/php/conf.d/docker-php-ext-swoole.ini && \ +RUN apt update \ + && apt install -y libaio-dev libc-ares-dev libaio1 supervisor wget git \ + && wget -nv https://download.oracle.com/otn_software/linux/instantclient/instantclient-basiclite-linuxx64.zip \ + && unzip instantclient-basiclite-linuxx64.zip && rm instantclient-basiclite-linuxx64.zip \ + && wget -nv https://download.oracle.com/otn_software/linux/instantclient/instantclient-sdk-linuxx64.zip \ + && unzip instantclient-sdk-linuxx64.zip && rm instantclient-sdk-linuxx64.zip \ + && mv instantclient_*_* ./instantclient \ + && rm ./instantclient/sdk/include/ldap.h \ + && echo DISABLE_INTERRUPT=on > ./instantclient/network/admin/sqlnet.ora \ + && mv ./instantclient /usr/local/ \ + && echo '/usr/local/instantclient' > /etc/ld.so.conf.d/oracle-instantclient.conf \ + && ldconfig \ + && export ORACLE_HOME=instantclient,/usr/local/instantclient \ + && apt install -y sqlite3 libsqlite3-dev libpq-dev \ + && pecl update-channels \ + && docker-php-ext-install mysqli \ + && docker-php-ext-enable mysqli \ + && docker-php-ext-install pdo_pgsql \ + && docker-php-ext-enable pdo_pgsql \ + && docker-php-ext-install pdo_oci \ + && docker-php-ext-enable pdo_oci \ + && docker-php-ext-install pdo_sqlite \ + && docker-php-ext-enable pdo_sqlite \ + && git clone https://github.com/swoole/swoole-src.git \ + && cd ./swoole-src \ + && phpize \ + && ./configure --enable-openssl \ + --enable-sockets \ + --enable-mysqlnd \ + --enable-swoole-curl \ + --enable-cares \ + --enable-swoole-pgsql \ + --with-swoole-oracle=instantclient,/usr/local/instantclient \ + --enable-swoole-sqlite \ + && make -j$(cat /proc/cpuinfo | grep processor | wc -l) \ + && make install \ + && docker-php-ext-enable swoole \ + && php -m \ + && php --ri swoole \ + && echo "swoole.enable_library=off" >> /usr/local/etc/php/conf.d/docker-php-ext-swoole.ini && \ { \ echo '[supervisord]'; \ echo 'user = root'; \ diff --git a/docker-compose.yml b/docker-compose.yml index 6011987..1a38657 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: image: swoole-library links: - mysql + - pgsql + - oracle - redis - wordpress - nacos @@ -67,6 +69,22 @@ services: MYSQL_PASSWORD: password MYSQL_ROOT_PASSWORD: password + pgsql: + container_name: swoole-library-pgsql + image: postgres:14 + environment: + POSTGRES_USER: root + POSTGRES_DB: test + POSTGRES_PASSWORD: root + + oracle: + container_name: swoole-library-oracle + image: gvenzl/oracle-xe:slim + environment: + ORACLE_PASSWORD: oracle + ports: + - "1521:1521" + redis: container_name: swoole-library-redis image: redis:5.0 diff --git a/src/__init__.php b/src/__init__.php index e511283..30b6070 100644 --- a/src/__init__.php +++ b/src/__init__.php @@ -40,6 +40,7 @@ 'core/Database/MysqliPool.php', 'core/Database/MysqliProxy.php', 'core/Database/MysqliStatementProxy.php', + 'core/Database/DetectsLostConnections.php', 'core/Database/PDOConfig.php', 'core/Database/PDOPool.php', 'core/Database/PDOProxy.php', diff --git a/src/core/Database/DetectsLostConnections.php b/src/core/Database/DetectsLostConnections.php new file mode 100644 index 0000000..0df2f6b --- /dev/null +++ b/src/core/Database/DetectsLostConnections.php @@ -0,0 +1,81 @@ +getMessage(); + foreach (self::ERROR_MESSAGES as $needle) { + if ($needle !== '' && mb_strpos($message, $needle) !== false) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/core/Database/PDOConfig.php b/src/core/Database/PDOConfig.php index bae200a..6b407a4 100644 --- a/src/core/Database/PDOConfig.php +++ b/src/core/Database/PDOConfig.php @@ -24,8 +24,8 @@ class PDOConfig /** @var int */ protected $port = 3306; - /** @var null|string */ - protected $unixSocket; + /** @var string */ + protected $unixSocket = ''; /** @var string */ protected $dbname = 'test'; @@ -71,7 +71,7 @@ public function getPort(): int public function hasUnixSocket(): bool { - return isset($this->unixSocket); + return !empty($this->unixSocket); } public function getUnixSocket(): string diff --git a/src/core/Database/PDOPool.php b/src/core/Database/PDOPool.php index 777c99f..7ead406 100644 --- a/src/core/Database/PDOPool.php +++ b/src/core/Database/PDOPool.php @@ -11,11 +11,11 @@ namespace Swoole\Database; +use Exception; use PDO; use Swoole\ConnectionPool; /** - * @method \PDO|PDOProxy get() * @method void put(PDO|PDOProxy $connection) */ class PDOPool extends ConnectionPool @@ -31,22 +31,48 @@ public function __construct(PDOConfig $config, int $size = self::DEFAULT_SIZE) $this->config = $config; parent::__construct(function () { $driver = $this->config->getDriver(); - return new \PDO( - "{$driver}:" . - ( - $this->config->hasUnixSocket() ? - "unix_socket={$this->config->getUnixSocket()};" : - "host={$this->config->getHost()};port={$this->config->getPort()};" - ) . - "dbname={$this->config->getDbname()};" . - ( - ($driver !== 'pgsql') ? - "charset={$this->config->getCharset()}" : '' - ), - $this->config->getUsername(), - $this->config->getPassword(), - $this->config->getOptions() - ); + if ($driver === 'sqlite') { + return new PDO($this->createDSN('sqlite')); + } + + return new PDO($this->createDSN($driver), $this->config->getUsername(), $this->config->getPassword(), $this->config->getOptions()); }, $size, PDOProxy::class); } + + public function get(float $timeout = -1) + { + $pdo = parent::get($timeout); + /* @var \Swoole\Database\PDOProxy $pdo */ + $pdo->reset(); + return $pdo; + } + + /** + * @purpose create DSN + * @throws Exception + */ + private function createDSN(string $driver): string + { + switch ($driver) { + case 'mysql': + if ($this->config->hasUnixSocket()) { + $dsn = "mysql:unix_socket={$this->config->getUnixSocket()};dbname={$this->config->getDbname()};charset={$this->config->getCharset()}"; + } else { + $dsn = "mysql:host={$this->config->getHost()};port={$this->config->getPort()};dbname={$this->config->getDbname()};charset={$this->config->getCharset()}"; + } + break; + case 'pgsql': + $dsn = 'pgsql:host=' . ($this->config->hasUnixSocket() ? $this->config->getUnixSocket() : $this->config->getHost()) . ";port={$this->config->getPort()};dbname={$this->config->getDbname()}"; + break; + case 'oci': + $dsn = 'oci:dbname=' . ($this->config->hasUnixSocket() ? $this->config->getUnixSocket() : $this->config->getHost()) . ':' . $this->config->getPort() . '/' . $this->config->getDbname() . ';charset=' . $this->config->getCharset(); + break; + case 'sqlite': + $dsn = 'sqlite:' . $this->config->getDbname(); + break; + default: + throw new Exception('Unsupported Database Driver:' . $driver); + } + return $dsn; + } } diff --git a/src/core/Database/PDOProxy.php b/src/core/Database/PDOProxy.php index 6ad37ed..e4d7b23 100644 --- a/src/core/Database/PDOProxy.php +++ b/src/core/Database/PDOProxy.php @@ -11,15 +11,12 @@ namespace Swoole\Database; +use PDO; +use PDOException; + class PDOProxy extends ObjectProxy { - public const IO_ERRORS = [ - 2002, // MYSQLND_CR_CONNECTION_ERROR - 2006, // MYSQLND_CR_SERVER_GONE_ERROR - 2013, // MYSQLND_CR_SERVER_LOST - ]; - - /** @var \PDO */ + /** @var PDO */ protected $__object; /** @var null|array */ @@ -31,46 +28,41 @@ class PDOProxy extends ObjectProxy /** @var int */ protected $round = 0; + /** @var int */ + protected $inTransaction = 0; + public function __construct(callable $constructor) { parent::__construct($constructor()); - $this->__object->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT); + $this->__object->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->constructor = $constructor; } public function __call(string $name, array $arguments) { - for ($n = 3; $n--;) { - $ret = @$this->__object->{$name}(...$arguments); - if ($ret === false) { - $errorInfo = $this->__object->errorInfo(); - if (empty($errorInfo)) { - break; - } - /* no more chances or non-IO failures */ - if ( - !in_array($errorInfo[1], static::IO_ERRORS, true) - || $n === 0 - || $this->__object->inTransaction() - ) { - /* '00000' means “no error.”, as specified by ANSI SQL and ODBC. */ - if (!empty($errorInfo) && $errorInfo[0] !== '00000') { - $exception = new \PDOException($errorInfo[2], $errorInfo[1]); - $exception->errorInfo = $errorInfo; - throw $exception; - } - /* no error info, just return false */ - break; - } + try { + $ret = $this->__object->{$name}(...$arguments); + } catch (PDOException $e) { + if (!$this->__object->inTransaction() && DetectsLostConnections::causedByLostConnection($e)) { $this->reconnect(); - continue; + $ret = $this->__object->{$name}(...$arguments); + } else { + throw $e; } - if ((strcasecmp($name, 'prepare') === 0) || (strcasecmp($name, 'query') === 0)) { - $ret = new PDOStatementProxy($ret, $this); - } - break; } - /* @noinspection PhpUndefinedVariableInspection */ + + if (strcasecmp($name, 'beginTransaction') === 0) { + $this->inTransaction++; + } + + if ((strcasecmp($name, 'commit') === 0 || strcasecmp($name, 'rollback') === 0) && $this->inTransaction > 0) { + $this->inTransaction--; + } + + if ((strcasecmp($name, 'prepare') === 0) || (strcasecmp($name, 'query') === 0)) { + $ret = new PDOStatementProxy($ret, $this); + } + return $ret; } @@ -83,6 +75,7 @@ public function reconnect(): void { $constructor = $this->constructor; parent::__construct($constructor()); + $this->__object->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->round++; /* restore context */ if ($this->setAttributeContext) { @@ -100,6 +93,11 @@ public function setAttribute(int $attribute, $value): bool public function inTransaction(): bool { - return $this->__object->inTransaction(); + return $this->inTransaction > 0; + } + + public function reset(): void + { + $this->inTransaction = 0; } } diff --git a/src/core/Database/PDOStatementProxy.php b/src/core/Database/PDOStatementProxy.php index 64fca57..4a9eda3 100644 --- a/src/core/Database/PDOStatementProxy.php +++ b/src/core/Database/PDOStatementProxy.php @@ -11,9 +11,12 @@ namespace Swoole\Database; +use PDOException; +use PDOStatement; + class PDOStatementProxy extends ObjectProxy { - /** @var \PDOStatement */ + /** @var PDOStatement */ protected $__object; /** @var null|array */ @@ -37,7 +40,7 @@ class PDOStatementProxy extends ObjectProxy /** @var int */ protected $parentRound; - public function __construct(\PDOStatement $object, PDOProxy $parent) + public function __construct(PDOStatement $object, PDOProxy $parent) { parent::__construct($object); $this->parent = $parent; @@ -46,40 +49,17 @@ public function __construct(\PDOStatement $object, PDOProxy $parent) public function __call(string $name, array $arguments) { - for ($n = 3; $n--;) { - $ret = @$this->__object->{$name}(...$arguments); - if ($ret === false) { - $errorInfo = $this->__object->errorInfo(); - if (empty($errorInfo)) { - break; - } - /* no more chances or non-IO failures or in transaction */ - if ( - !in_array($errorInfo[1], $this->parent::IO_ERRORS, true) - || $n === 0 - || $this->parent->inTransaction() - ) { - /* '00000' means “no error.”, as specified by ANSI SQL and ODBC. */ - if (!empty($errorInfo) && $errorInfo[0] !== '00000') { - $exception = new \PDOException($errorInfo[2], $errorInfo[1]); - $exception->errorInfo = $errorInfo; - throw $exception; - } - /* no error info, just return false */ - break; - } + try { + $ret = $this->__object->{$name}(...$arguments); + } catch (PDOException $e) { + if (!$this->parent->inTransaction() && DetectsLostConnections::causedByLostConnection($e)) { if ($this->parent->getRound() === $this->parentRound) { /* if not equal, parent has reconnected */ $this->parent->reconnect(); } $parent = $this->parent->__getObject(); $this->__object = $parent->prepare($this->__object->queryString); - if ($this->__object === false) { - $errorInfo = $parent->errorInfo(); - $exception = new \PDOException($errorInfo[2], $errorInfo[1]); - $exception->errorInfo = $errorInfo; - throw $exception; - } + if ($this->setAttributeContext) { foreach ($this->setAttributeContext as $attribute => $value) { $this->__object->setAttribute($attribute, $value); @@ -103,11 +83,12 @@ public function __call(string $name, array $arguments) $this->__object->bindParam($value, ...$item); } } - continue; + $ret = $this->__object->{$name}(...$arguments); + } else { + throw $e; } - break; } - /* @noinspection PhpUndefinedVariableInspection */ + return $ret; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 974737a..1ca3524 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -28,6 +28,22 @@ define('MYSQL_SERVER_DB', 'test'); } +if (!defined('PGSQL_SERVER_HOST')) { + define('PGSQL_SERVER_HOST', 'pgsql'); + define('PGSQL_SERVER_PORT', 5432); + define('PGSQL_SERVER_USER', 'root'); + define('PGSQL_SERVER_PWD', 'root'); + define('PGSQL_SERVER_DB', 'test'); +} + +if (!defined('ORACLE_SERVER_HOST')) { + define('ORACLE_SERVER_HOST', 'oracle'); + define('ORACLE_SERVER_PORT', 1521); + define('ORACLE_SERVER_USER', 'system'); + define('ORACLE_SERVER_PWD', 'oracle'); + define('ORACLE_SERVER_DB', 'xe'); +} + if (!defined('REDIS_SERVER_HOST')) { define('REDIS_SERVER_HOST', 'redis'); define('REDIS_SERVER_PORT', 6379); diff --git a/tests/unit/Database/PDOPoolTest.php b/tests/unit/Database/PDOPoolTest.php index a4b5767..f5a0ce1 100644 --- a/tests/unit/Database/PDOPoolTest.php +++ b/tests/unit/Database/PDOPoolTest.php @@ -11,10 +11,15 @@ namespace Swoole\Database; +use PDO; use PHPUnit\Framework\TestCase; use Swoole\Coroutine; +use Swoole\Coroutine\WaitGroup; use Swoole\Tests\HookFlagsTrait; +use function Swoole\Coroutine\go; +use function Swoole\Coroutine\run; + /** * Class PDOPoolTest * @@ -47,7 +52,7 @@ public function testPutWhenErrorHappens() try { $statement = $pdo->prepare('SELECT :n as n'); $statement->execute([':n' => $n]); - $row = $statement->fetch(\PDO::FETCH_ASSOC); + $row = $statement->fetch(PDO::FETCH_ASSOC); // simulate error happens $statement = $pdo->prepare('KILL CONNECTION_ID()'); $statement->execute(); @@ -65,4 +70,133 @@ public function testPutWhenErrorHappens() $this->assertEquals($expect, $actual); self::restoreHookFlags(); } + + public function testPostgres(): void + { + self::saveHookFlags(); + self::setHookFlags(SWOOLE_HOOK_ALL); + run(function () { + $config = (new PDOConfig()) + ->withDriver('pgsql') + ->withHost(PGSQL_SERVER_HOST) + ->withPort(PGSQL_SERVER_PORT) + ->withDbName(PGSQL_SERVER_DB) + ->withUsername(PGSQL_SERVER_USER) + ->withPassword(PGSQL_SERVER_PWD); + $pool = new PDOPool($config, 10); + + $pdo = $pool->get(); + $pdo->exec( + <<<'EOF' +create table test(id int); +EOF + ); + $pool->put($pdo); + + $waitGroup = new WaitGroup(); + for ($i = 0; $i < 30; $i++) { + go(function () use ($pool, $i, $waitGroup) { + $waitGroup->add(); + $pdo = $pool->get(); + $statement = $pdo->prepare('INSERT INTO test VALUES(?)'); + $statement->execute([$i]); + + $statement = $pdo->prepare('SELECT id FROM test where id = ?'); + $statement->execute([$i]); + $result = $statement->fetch(PDO::FETCH_ASSOC); + $this->assertEquals($result['id'], $i); + $pool->put($pdo); + $waitGroup->done(); + }); + } + + $waitGroup->wait(); + self::restoreHookFlags(); + }); + } + + public function testOracle(): void + { + self::saveHookFlags(); + self::setHookFlags(SWOOLE_HOOK_ALL); + run(function () { + $config = (new PDOConfig()) + ->withDriver('oci') + ->withHost(ORACLE_SERVER_HOST) + ->withPort(ORACLE_SERVER_PORT) + ->withDbName(ORACLE_SERVER_DB) + ->withCharset('AL32UTF8') + ->withUsername(ORACLE_SERVER_USER) + ->withPassword(ORACLE_SERVER_PWD); + $pool = new PDOPool($config, 10); + + $pdo = $pool->get(); + $pdo->exec( + <<<'EOF' +create table test(id INTEGER) +EOF + ); + $pool->put($pdo); + + $waitGroup = new WaitGroup(); + for ($i = 0; $i < 30; $i++) { + go(function () use ($pool, $i, $waitGroup) { + $waitGroup->add(); + $pdo = $pool->get(); + $statement = $pdo->prepare('INSERT INTO test VALUES(?)'); + $statement->execute([$i]); + + $statement = $pdo->prepare('SELECT id FROM test where id = ?'); + $statement->execute([$i]); + $result = $statement->fetch(PDO::FETCH_ASSOC); + $this->assertEquals($result['ID'], $i); + $pool->put($pdo); + $waitGroup->done(); + }); + } + + $waitGroup->wait(); + self::restoreHookFlags(); + }); + } + + public function testSqlite(): void + { + self::saveHookFlags(); + self::setHookFlags(SWOOLE_HOOK_ALL); + run(function () { + $config = (new PDOConfig()) + ->withDriver('sqlite') + ->withHost('sqlite::memory:'); + $pool = new PDOPool($config, 10); + + $pdo = $pool->get(); + $pdo->exec( + <<<'EOF' +create table test(id int); +EOF + ); + $pool->put($pdo); + + $waitGroup = new WaitGroup(); + for ($i = 0; $i < 30; $i++) { + go(function () use ($pool, $i, $waitGroup) { + $waitGroup->add(); + $pdo = $pool->get(); + $statement = $pdo->prepare('INSERT INTO test VALUES(?)'); + $statement->execute([$i]); + + $statement = $pdo->prepare('SELECT id FROM test where id = ?'); + $statement->execute([$i]); + $result = $statement->fetch(PDO::FETCH_ASSOC); + $this->assertEquals($result['id'], $i); + $pool->put($pdo); + $waitGroup->done(); + }); + } + + $waitGroup->wait(); + self::restoreHookFlags(); + }); + } }