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
2 changes: 1 addition & 1 deletion .claude/skills/adapter-dbconnection/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ ConnectionPool → writer + readers[], health checks, load balancing, failove
```
Connections always injected — never created internally by consumers.

**Lazy:** `mysql()`/`postgres()` open no socket. The connection opens on first access (`pdo()`, a query, a transaction); a request without DB access opens no connection. A connection error is thrown there (same `RuntimeException`), not at creation. `isConnected()` is `false` until the first access; `connect()` connects eagerly; an explicit `disconnect()` is not reopened implicitly. `sqlite()` opens its local file at creation.
**Lazy:** `mysql()`/`postgres()` open no socket. The connection opens on first access (`pdo()`, a query, a transaction); a request without DB access opens no connection. A connection error is thrown there (same `RuntimeException`), not at creation. `isConnected()` is `false` until the first access; `connect()` connects eagerly; an explicit `disconnect()` is not reopened implicitly. SQLite opens the file at creation (intended: local file); mysql/pgsql connect on first access. A failed pool health check names its cause: `RuntimeException('Writer connection health check failed: <cause>', previous: $e)` (same for readers).

## FACTORY SIGNATURES
```php
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ A PDO connection pool with read/write splitting for PHP — round-robin load bal
- **Health Checks** — `SELECT 1` validation with positive and negative result caching
- **Transaction Support** — `beginTransaction()`, `commit()`, `rollback()`, `inTransaction()` on every connection
- **Savepoints** — `savepoint()`, `releaseSavepoint()`, `rollbackToSavepoint()` for nested transaction brackets (`SavepointInterface`, MySQL / MariaDB / PostgreSQL / SQLite)
- **Lazy Connections** — `mysql()`/`postgres()` open no socket; the connection opens on first access, so a request without DB access opens none. Connection errors surface at first access
- **Lazy Connections** — `mysql()`/`postgres()` open no socket; the connection opens on first access, so a request without DB access opens none. Connection errors surface at first access; SQLite opens its (local) file at creation by design, mysql/pgsql connect on first access. A failed pool health check names its cause: `Writer connection health check failed: <cause>` (the connection error is also `getPrevious()`)
- **Reconnect** — `reconnect()` rebuilds the PDO connection from config on failure
- **MySQL / PostgreSQL / SQLite** — Dedicated factory methods per driver with typed config
- **External PDO Wrapping** — `fromPdo()` integrates existing connections without refactoring
Expand Down
67 changes: 47 additions & 20 deletions src/ConnectionPool.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ConnectionPool implements ConnectionPoolInterface
/** @var array<DbConnectionInterface> */
private array $readers;
private int $currentReaderIndex = 0;
/** @var array<string, array{healthy: bool, timestamp: int}> */
/** @var array<string, array{cause: ?\Throwable, timestamp: int}> */
private array $healthCache = [];
private ConnectionPoolConfig $config;
/** @var array{reads: int, writes: int, failovers: int} */
Expand Down Expand Up @@ -49,9 +49,7 @@ public function __construct(
*/
public function getWriter(): DbConnectionInterface
{
if ($this->config->validateConnections && !$this->isHealthy($this->writer)) {
throw new RuntimeException('Writer connection health check failed');
}
$this->assertWriterHealthy();

$this->stats['writes']++;

Expand All @@ -76,9 +74,7 @@ public function getWriter(): DbConnectionInterface
public function getReader(): DbConnectionInterface
{
if ($this->config->stickyWriterDuringTransaction && $this->writer->inTransaction()) {
if ($this->config->validateConnections && !$this->isHealthy($this->writer)) {
throw new RuntimeException('Writer connection health check failed');
}
$this->assertWriterHealthy();

$this->stats['reads']++;

Expand All @@ -88,21 +84,28 @@ public function getReader(): DbConnectionInterface
$effectiveReaders = $this->getEffectiveReaders();
/** @var array<string, true> $tried */
$tried = [];
$lastCause = null;

while (count($tried) < count($effectiveReaders)) {
$connection = $this->selectReader($effectiveReaders, $tried);

if (!$this->config->validateConnections || $this->isHealthy($connection)) {
$cause = $this->config->validateConnections ? $this->healthFailure($connection) : null;
if ($cause === null) {
$this->stats['reads']++;
return $connection;
}

$lastCause = $cause;

$this->stats['failovers']++;
$tried[spl_object_hash($connection)] = true;
}

throw new RuntimeException(
'All ' . count($effectiveReaders) . ' reader connections are unavailable'
. ($lastCause !== null ? ': ' . $lastCause->getMessage() : ''),
0,
$lastCause
);
}

Expand Down Expand Up @@ -198,46 +201,70 @@ private function selectRandomReader(array $readers, array $excluded = []): DbCon
return $candidates[array_rand($candidates)];
}

private function isHealthy(DbConnectionInterface $connection): bool
/**
* Single handler for the writer health gate (used by getWriter() and the sticky-writer path).
*
* @throws RuntimeException With the connection error as message suffix and as previous
*/
private function assertWriterHealthy(): void
{
if (!$this->config->validateConnections) {
return;
}

$cause = $this->healthFailure($this->writer);
if ($cause !== null) {
throw new RuntimeException(
'Writer connection health check failed: ' . $cause->getMessage(),
0,
$cause
);
}
}

/**
* @return \Throwable|null The cause when unhealthy, null when healthy
*/
private function healthFailure(DbConnectionInterface $connection): ?\Throwable
{
$key = spl_object_hash($connection);
$now = time();

if (isset($this->healthCache[$key])) {
$cached = $this->healthCache[$key];
$ttl = $cached['healthy']
$ttl = $cached['cause'] === null
? $this->config->healthCheckCacheTtl
: $this->config->healthCheckNegativeCacheTtl;

if ($ttl > 0 && ($now - $cached['timestamp']) < $ttl) {
return $cached['healthy'];
return $cached['cause'];
}
}

$healthy = $this->performHealthCheck($connection);
$cause = $this->performHealthCheck($connection);

$this->healthCache[$key] = [
'healthy' => $healthy,
'cause' => $cause,
'timestamp' => $now,
];

return $healthy;
return $cause;
}

private function performHealthCheck(DbConnectionInterface $connection): bool
private function performHealthCheck(DbConnectionInterface $connection): ?\Throwable
{
try {
$pdo = $connection->pdo();
$stmt = $pdo->query('SELECT 1');
return $stmt !== false;
} catch (\Exception $e) {
return $stmt !== false ? null : new RuntimeException('SELECT 1 returned no result');
} catch (\Exception) {
try {
$connection->reconnect();
$pdo = $connection->pdo();
$stmt = $pdo->query('SELECT 1');
return $stmt !== false;
} catch (\Exception) {
return false;
return $stmt !== false ? null : new RuntimeException('SELECT 1 returned no result');
} catch (\Exception $e) {
return $e;
}
}
}
Expand Down
19 changes: 18 additions & 1 deletion tests/Unit/LazyConnectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,28 @@ public function testPoolBuildsWithoutConnectingAndFailsOnFirstAccess(): void
$this->assertFalse($writer->isConnected());

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Writer connection health check failed');
$this->expectExceptionMessage('Writer connection health check failed: ');

$pool->getWriter();
}

public function testHealthCheckFailureCarriesConnectionCause(): void
{
$writer = (new ConnectionFactory())->mysql('127.0.0.1', 'u', 'p', 'db', 1);
$pool = new ConnectionPool($writer, config: new ConnectionPoolConfig(validateConnections: true));

try {
$pool->getWriter();
$this->fail('Expected RuntimeException');
} catch (RuntimeException $e) {
$cause = $e->getPrevious();
$this->assertInstanceOf(RuntimeException::class, $cause);
$this->assertStringContainsString($cause->getMessage(), $e->getMessage());
$this->assertInstanceOf(\PDOException::class, $cause->getPrevious());
$this->assertStringContainsString($cause->getPrevious()->getMessage(), $e->getMessage());
}
}

public function testPoolWithoutHealthCheckDoesNotConnectOnGetWriter(): void
{
$writer = (new ConnectionFactory())->mysql('127.0.0.1', 'u', 'p', 'db', 1);
Expand Down
Loading