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
15 changes: 15 additions & 0 deletions .docker/ci/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM devitools/php-hyperf:8.3-dev@sha256:285435f969f1309e78e24a9b432f92f44dd6753621b10b5faa94affd66a1b925

RUN apk add --no-cache --virtual .mongodb-build-deps autoconf g++ make openssl-dev php83-dev \
&& curl --fail --location --retry 3 https://pecl.php.net/get/mongodb-1.21.12.tgz -o /tmp/mongodb.tgz \
&& echo 'd4d2a8ad034685eacd7ea3ed96c0c9aed8c0165ea6e79d106bdf95214d5eaa35 /tmp/mongodb.tgz' | sha256sum -c - \
&& tar -xzf /tmp/mongodb.tgz -C /tmp \
&& cd /tmp/mongodb-1.21.12 \
&& phpize83 \
&& ./configure --with-php-config=/usr/bin/php-config83 --with-mongodb-ssl=openssl \
&& make -j2 \
&& make install \
&& cd / \
&& rm -rf /tmp/mongodb* \
&& apk del .mongodb-build-deps \
&& php -r 'exit(PHP_VERSION_ID >= 80336 && phpversion("mongodb") === "1.21.12" ? 0 : 1);'
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ jobs:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

steps:
- name: Cache Docker images
uses: ScribeMD/docker-cache@0.5.0
with:
key: docker-${{ runner.os }}-${{ hashFiles('compose.yaml') }}

- name: Checkout current code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Cache Docker images
uses: ScribeMD/docker-cache@0.5.0
with:
key: docker-${{ runner.os }}-${{ hashFiles('compose.yml', '.docker/ci/Dockerfile') }}

- name: Configure Git permissions
run: git config --global --add safe.directory /__w/serendipity/serendipity

Expand Down
5 changes: 3 additions & 2 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
services:
app:
container_name: serendipity-app
image: devitools/hyperf:8.3-dev
build:
context: .docker/ci
volumes:
- ./:/opt/www
environment:
Expand Down Expand Up @@ -53,7 +54,7 @@ services:
volumes:
- serendipity-mongo-data:/data/db
healthcheck:
test: test $$(echo "rs.initiate().ok || rs.status().ok" | mongo -u user -p password --quiet) -eq 1
test: [ "CMD", "mongosh", "--quiet", "--eval", "quit(db.hello().isWritablePrimary ? 0 : 1)" ]
interval: 10s
start_period: 30s
networks:
Expand Down
1 change: 1 addition & 0 deletions deptrac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ deptrac:
^Visus\\Cuid2\\.*|\
^Sentry\\.*|\
^Swow\\.*|\
^Swoole\\.*|\
^MongoDB\\.*|\
^Symfony\\.*|\
^Hyperf\\(?!Contract|Validation|Context\\Context).*|\
Expand Down
37 changes: 37 additions & 0 deletions docs/postgresql-session-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# PostgreSQL session recovery

Session recovery is an explicit opt-in for the `hyperf/db` PostgreSQL driver.
It keeps the existing Swoole PostgreSQL backend and the Serendipity relational
connection interfaces. It does not switch to PDO or enable read/write routing.

Add its bindings to the application's `config/autoload/dependencies.php`:

```php
use Serendipity\Hyperf\Database\Relational\PostgreSQL\PostgreSQLSessionRecovery;

return array_replace($existingDependencies, PostgreSQLSessionRecovery::dependencies());
```

The application must provide its usual `Psr\Log\LoggerInterface` binding and a
runtime with `Swoole\Coroutine\PostgreSQL` support. The binding applies to
PostgreSQL pools created by `Hyperf\DB\Pool\PoolFactory`; other drivers and
applications that do not register the binding retain their existing behavior.

The failed operation throws its original exception and is never automatically
replayed. A transport or protocol failure invalidates the session. Release
discards the physical connection while returning the managed object to the
native pool; a later acquisition establishes a fresh session. An invalid active
transaction cannot reconnect or continue. Its rollback only resets local state
and discards the session. Structured SQL failures keep healthy sessions usable
after the normal transaction rollback.

The adapter preserves diagnostics when native error messages are missing.
Invalidation warnings contain the pool, stage, category, SQLSTATE, driver code
and result status when available. They do not include SQL, bindings or credentials.
Cleanup and logging failures do not replace the operation's original exception.

A connection failure during `COMMIT` leaves the transaction outcome uncertain;
the caller must not assume rollback or retry the write automatically. Application
transaction helpers must also preserve the initial exception if cleanup fails.
This module does not introduce a transaction callback runner, partner request
retries, tracing changes, infrastructure changes or deployment behavior.
2 changes: 1 addition & 1 deletion makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ watch: ## Start the project in watch mode
##@ Composer

install: ## Composer install dependencies
@$(COMPOSE_RUNNER) --profile mongo --profile postgres run --rm --entrypoint composer app install
@$(COMPOSE_RUNNER) --profile mongo --profile postgres run --rm --build --entrypoint composer app install

dump: ## Run the composer dump
@$(COMPOSE_RUNNER) run --rm --entrypoint composer app dump-autoload
Expand Down
2 changes: 0 additions & 2 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

declare(strict_types=1);

use Rector\Caching\ValueObject\Storage\FileCacheStorage;
use Rector\CodeQuality\Rector\Class_\InlineConstructorDefaultToPropertyRector;
use Rector\Config\RectorConfig;
use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector;
Expand Down Expand Up @@ -33,6 +32,5 @@
LevelSetList::UP_TO_PHP_83,
]);

$config->cacheClass(FileCacheStorage::class);
$config->cacheDirectory('/tmp/rector');
};
5 changes: 4 additions & 1 deletion src/Domain/Exception/RepositoryException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Exception;
use Throwable;

use function is_int;

class RepositoryException extends Exception
{
public readonly ThrowableType $type;
Expand All @@ -18,9 +20,10 @@ public function __construct(
?string $message = null,
?int $code = null,
) {
$previousCode = $previous?->getCode();
parent::__construct(
$message ?? $previous?->getMessage() ?? '',
$code ?? $previous?->getCode() ?? 0,
$code ?? (is_int($previousCode) ? $previousCode : 0),
$previous
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace Serendipity\Hyperf\Database\Relational\PostgreSQL;

use Closure;
use Hyperf\DB\Exception\QueryException;
use Hyperf\DB\PgSQL\PgSQLConnection;
use Hyperf\Pool\Pool;
use Psr\Container\ContainerInterface;
use Throwable;

use function microtime;

class DiagnosticPgSQLConnection extends PgSQLConnection
{
public function __construct(
ContainerInterface $container,
Pool $pool,
array $config,
private readonly PgSQLSession $session,
private readonly PgSQLConnector $connector,
private readonly PgSQLStatements $statements,
private readonly PgSQLCommands $commands,
) {
parent::__construct($container, $pool, $config);
}

public function check(): bool
{
return $this->session->isValid() && isset($this->connection) && ($this->transactions > 0 || parent::check());
}

public function getConnection(): static
{
if ($this->transactions > 0 && ! $this->check()) {
return $this;
}
$this->getActiveConnection();
return $this;
}

public function reconnect(): bool
{
if ($this->transactions > 0) {
throw new QueryException('Cannot reconnect an active PostgreSQL transaction.');
}
$this->session->discard(fn () => parent::close());
$this->connection = $this->connector->connect($this->config, $this->session);
$this->session->activate();
$this->lastUseTime = microtime(true);
return true;
}

public function execute(string $query, array $bindings = []): int
{
return $this->statements->execute($this->session, $this->connection ?? null, $query, $bindings);
}

public function query(string $query, array $bindings = []): array
{
return $this->statements->query($this->session, $this->connection ?? null, $query, $bindings);
}

public function call(string $method, array $argument = []): mixed
{
$callback = fn () => parent::call($method, $argument);
return $this->commands->call($this->session, $this->connection ?? null, $method, $callback);
}

public function run(Closure $closure): mixed
{
return $this->commands->run($this->session, $this->connection ?? null, fn () => parent::run($closure));
}

public function rollBack(?int $toLevel = null): void
{
if (! $this->session->isValid() || ! isset($this->connection)) {
$this->transactions = 0;
$this->session->discard(fn () => parent::close());
return;
}
parent::rollBack($toLevel);
}

public function release(): void
{
$snapshot = fn () => PgSQLFailure::from($this->connection ?? null);
$this->commands->release($this->session, $this, $snapshot, fn () => parent::release());
}

protected function causedByLostConnection(Throwable $exception): bool
{
return false;
}
}
23 changes: 23 additions & 0 deletions src/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLPool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Serendipity\Hyperf\Database\Relational\PostgreSQL;

use Hyperf\Contract\ConnectionInterface;
use Hyperf\DB\PgSQL\PgSQLPool;

use function Hyperf\Support\make;

class DiagnosticPgSQLPool extends PgSQLPool
{
protected function createConnection(): ConnectionInterface
{
return make(DiagnosticPgSQLConnection::class, [
'container' => $this->container,
'pool' => $this,
'config' => $this->config,
'session' => make(PgSQLSession::class, ['poolName' => $this->getName()]),
]);
}
}
69 changes: 69 additions & 0 deletions src/Hyperf/Database/Relational/PostgreSQL/PgSQLCommands.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Serendipity\Hyperf\Database\Relational\PostgreSQL;

use Closure;
use ErrorException;
use Hyperf\DB\AbstractConnection;
use Swoole\Coroutine\PostgreSQL;
use Throwable;

use function str_contains;

final class PgSQLCommands
{
public function call(PgSQLSession $session, ?PostgreSQL $driver, string $method, Closure $callback): mixed
{
$driver = $session->usable($driver);
$result = $session->call($method, $driver, $driver, $callback);
if ($result === false) {
$session->failure($method, $driver, $driver);
}
return $result;
}

public function run(PgSQLSession $session, ?PostgreSQL $driver, Closure $callback): mixed
{
$driver = $session->usable($driver);
try {
return $callback();
} catch (Throwable $exception) {
if ($exception instanceof ErrorException && str_contains($exception->getMessage(), PostgreSQL::class)) {
$session->inspect('run', $driver, $driver, $exception);
}
throw $exception;
} finally {
$session->inspect('run', $driver, $driver);
}
}

/** @param Closure(): PgSQLFailure $snapshot */
public function release(
PgSQLSession $session,
AbstractConnection $connection,
Closure $snapshot,
Closure $release,
): void {
$this->rollback($session, $connection, $snapshot);
if (! $session->isValid()) {
$session->discard(fn () => $connection->close());
}
$session->clean('release', $release);
}

/** @param Closure(): PgSQLFailure $snapshot */
private function rollback(PgSQLSession $session, AbstractConnection $connection, Closure $snapshot): void
{
try {
if ($connection->transactionLevel() > 0) {
$connection->rollBack(0);
}
} catch (Throwable $exception) {
$session->invalidate('release', $snapshot(), 'rollback_failed');
$session->clean('rollback', static fn () => throw $exception);
$connection->rollBack(0);
}
}
}
59 changes: 59 additions & 0 deletions src/Hyperf/Database/Relational/PostgreSQL/PgSQLConnector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Serendipity\Hyperf\Database\Relational\PostgreSQL;

use Swoole\Coroutine\PostgreSQL;
use Throwable;

use function Constructo\Cast\stringify;
use function sprintf;

class PgSQLConnector
{
public function connect(array $config, PgSQLSession $session): PostgreSQL
{
$driver = $this->create();
try {
$this->initialize($driver, $config, $session);
$this->schema($driver, $config, $session);
return $driver;
} catch (Throwable $exception) {
$session->clean('connect', static function () use (&$driver): void {
$driver = null;
});
throw $exception;
}
}

public function create(): PostgreSQL
{
return new PostgreSQL();
}

private function initialize(PostgreSQL $driver, array $config, PgSQLSession $session): void
{
$dsn = sprintf(
'host=%s port=%s dbname=%s user=%s password=%s',
stringify($config['host']),
stringify($config['port']),
stringify($config['database']),
stringify($config['username']),
stringify($config['password']),
);
if ($session->call('connect', $driver, $driver, fn () => $driver->connect($dsn)) === false) {
$session->failure('connect', $driver, $driver);
}
}

private function schema(PostgreSQL $driver, array $config, PgSQLSession $session): void
{
if (! empty($config['schema'])) {
$query = sprintf('set search_path to %s', stringify($config['schema']));
if ($session->call('schema', $driver, $driver, fn () => $driver->query($query)) === false) {
$session->failure('schema', $driver, $driver);
}
}
}
}
Loading
Loading