From 634c2df2e9d3fd7aa6ddabf7458e4f338456466d Mon Sep 17 00:00:00 2001 From: Headgent Date: Wed, 14 Oct 2026 21:53:12 +0200 Subject: [PATCH] fix(pool): health check failure names the connection cause (#22) Writer/reader health check errors now carry the underlying connection error as message suffix and as previous exception. Writer gate shared between getWriter() and the sticky-writer path. Document that SQLite opens its local file at creation while mysql/pgsql connect lazily. --- .claude/skills/adapter-dbconnection/SKILL.md | 2 +- README.md | 2 +- src/ConnectionPool.php | 67 ++++++++++++++------ tests/Unit/LazyConnectionTest.php | 19 +++++- 4 files changed, 67 insertions(+), 23 deletions(-) diff --git a/.claude/skills/adapter-dbconnection/SKILL.md b/.claude/skills/adapter-dbconnection/SKILL.md index bd6a784..9521f07 100644 --- a/.claude/skills/adapter-dbconnection/SKILL.md +++ b/.claude/skills/adapter-dbconnection/SKILL.md @@ -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: ', previous: $e)` (same for readers). ## FACTORY SIGNATURES ```php diff --git a/README.md b/README.md index 7ca4c07..636aed2 100644 --- a/README.md +++ b/README.md @@ -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: ` (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 diff --git a/src/ConnectionPool.php b/src/ConnectionPool.php index 3853c51..6c3480f 100644 --- a/src/ConnectionPool.php +++ b/src/ConnectionPool.php @@ -21,7 +21,7 @@ class ConnectionPool implements ConnectionPoolInterface /** @var array */ private array $readers; private int $currentReaderIndex = 0; - /** @var array */ + /** @var array */ private array $healthCache = []; private ConnectionPoolConfig $config; /** @var array{reads: int, writes: int, failovers: int} */ @@ -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']++; @@ -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']++; @@ -88,21 +84,28 @@ public function getReader(): DbConnectionInterface $effectiveReaders = $this->getEffectiveReaders(); /** @var array $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 ); } @@ -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; } } } diff --git a/tests/Unit/LazyConnectionTest.php b/tests/Unit/LazyConnectionTest.php index a7ad7ad..1592a2f 100644 --- a/tests/Unit/LazyConnectionTest.php +++ b/tests/Unit/LazyConnectionTest.php @@ -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);