From 8a4fa69dfaa861d506294cbbd0ce0df0a5735c88 Mon Sep 17 00:00:00 2001 From: Headgent Development Date: Mon, 10 Aug 2026 09:05:49 +0200 Subject: [PATCH 1/2] feat(dbConnection): sticky writer during open transaction for getReader() Add opt-in ConnectionPoolConfig flag stickyWriterDuringTransaction; when set and the writer has an open transaction, getReader() returns the writer (same health check as getWriter()) instead of an independent reader, so reads inside a transaction see the same uncommitted state. Default false, no behavior change for existing consumers. Claude-Session: https://claude.ai/code/session_01B3myqC1R5NcWWMHxDh6zBH --- src/Config/ConnectionPoolConfig.php | 4 + src/ConnectionPool.php | 19 +- tests/Integration/ConnectionPoolTest.php | 116 ++++++++++++ .../Unit/Config/ConnectionPoolConfigTest.php | 15 ++ tests/Unit/ConnectionPoolTest.php | 174 ++++++++++++++++++ 5 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/ConnectionPoolTest.php diff --git a/src/Config/ConnectionPoolConfig.php b/src/Config/ConnectionPoolConfig.php index 0c58d04..20c05fe 100644 --- a/src/Config/ConnectionPoolConfig.php +++ b/src/Config/ConnectionPoolConfig.php @@ -27,6 +27,9 @@ * @param int $healthCheckCacheTtl TTL in seconds for caching positive health check results * @param int $healthCheckNegativeCacheTtl TTL in seconds for caching negative health check results (0 = no caching) * @param string $loadBalancingStrategy Strategy for distributing read queries (use STRATEGY_* constants) + * @param bool $stickyWriterDuringTransaction While the writer has an open transaction, + * getReader() returns the writer instead of an independent reader, so reads inside + * the transaction see the same uncommitted state the transaction is writing. * @throws InvalidArgumentException If any parameter value is invalid */ public function __construct( @@ -34,6 +37,7 @@ public function __construct( public int $healthCheckCacheTtl = 30, public int $healthCheckNegativeCacheTtl = 0, public string $loadBalancingStrategy = self::STRATEGY_ROUND_ROBIN, + public bool $stickyWriterDuringTransaction = false, ) { if ($healthCheckCacheTtl < 0) { throw new InvalidArgumentException('Health check cache TTL must be non-negative'); diff --git a/src/ConnectionPool.php b/src/ConnectionPool.php index 0587d78..3853c51 100644 --- a/src/ConnectionPool.php +++ b/src/ConnectionPool.php @@ -64,10 +64,27 @@ public function getWriter(): DbConnectionInterface * Automatically load-balances across available readers and performs * failover if a reader is unhealthy. Each reader is tried at most once. * - * @throws RuntimeException If all readers fail health checks + * If `stickyWriterDuringTransaction` is enabled and the writer currently has an open + * transaction, the writer is returned instead of an independent reader, so reads inside + * the transaction see the same uncommitted state the transaction is writing. The writer + * undergoes the same health check as `getWriter()` in this case — an unhealthy writer + * during a transaction fails loudly rather than silently falling back to a reader. + * + * @throws RuntimeException If all readers fail health checks, or the writer fails its + * health check while the sticky-writer path is active */ 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->stats['reads']++; + + return $this->writer; + } + $effectiveReaders = $this->getEffectiveReaders(); /** @var array $tried */ $tried = []; diff --git a/tests/Integration/ConnectionPoolTest.php b/tests/Integration/ConnectionPoolTest.php index 8047575..9592b6e 100644 --- a/tests/Integration/ConnectionPoolTest.php +++ b/tests/Integration/ConnectionPoolTest.php @@ -470,4 +470,120 @@ public function testGetReadersAndGetReaderShareConnections(): void 'getReader() should return an instance from getReaders()' ); } + + /** + * I1 (MySQL): uncommitteter Wert in offener Tx ist ueber getReader() sichtbar MIT Flag, + * NICHT sichtbar OHNE Flag. + */ + public function testStickyWriterMakesUncommittedWriteVisibleOverGetReaderMysql(): void + { + $this->assertStickyWriterVisibility( + $this->createWriter(), + $this->createReader(), + 'test_sticky_writer_mysql' + ); + } + + /** + * I1 (Postgres): uncommitteter Wert in offener Tx ist ueber getReader() sichtbar MIT Flag, + * NICHT sichtbar OHNE Flag. + */ + public function testStickyWriterMakesUncommittedWriteVisibleOverGetReaderPostgres(): void + { + $host = $_ENV['POSTGRES_HOST'] ?? 'postgres'; + $user = $_ENV['POSTGRES_USER'] ?? 'test_user'; + $password = $_ENV['POSTGRES_PASSWORD'] ?? 'test_password'; + $database = $_ENV['POSTGRES_DATABASE'] ?? 'test_db'; + $port = (int) ($_ENV['POSTGRES_PORT'] ?? 5432); + + $writer = $this->factory->postgres($host, $user, $password, $database, $port); + $reader = $this->factory->postgres($host, $user, $password, $database, $port); + + $this->assertStickyWriterVisibility($writer, $reader, 'test_sticky_writer_postgres'); + } + + /** + * I1 (SQLite): uncommitteter Wert in offener Tx ist ueber getReader() sichtbar MIT Flag, + * NICHT sichtbar OHNE Flag. Zwei getrennte Verbindungen auf dieselbe Datei (WAL-Modus), + * :memory: waere pro Verbindung isoliert und koennte die Grenze nicht zeigen. + */ + public function testStickyWriterMakesUncommittedWriteVisibleOverGetReaderSqlite(): void + { + $path = sys_get_temp_dir() . '/test_sticky_writer_' . uniqid() . '.db'; + + try { + $writer = $this->factory->sqlite($path); + $reader = $this->factory->sqlite($path); + + $this->assertStickyWriterVisibility($writer, $reader, 'test_sticky_writer_sqlite'); + } finally { + foreach ([$path, $path . '-wal', $path . '-shm'] as $file) { + if (file_exists($file)) { + unlink($file); + } + } + } + } + + /** + * Shared I1 body: creates a fixture table, opens a writer transaction with an + * uncommitted insert, and asserts getReader() visibility with and without the + * sticky-writer flag — against two genuinely separate connections. + */ + private function assertStickyWriterVisibility( + DbConnectionInterface $writer, + DbConnectionInterface $reader, + string $table + ): void { + $pdo = $writer->pdo(); + $pdo->exec("DROP TABLE IF EXISTS {$table}"); + $pdo->exec("CREATE TABLE {$table} (id INTEGER PRIMARY KEY, value VARCHAR(100))"); + + try { + $poolWithoutFlag = new ConnectionPool( + writer: $writer, + readers: [$reader], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: false) + ); + $poolWithFlag = new ConnectionPool( + writer: $writer, + readers: [$reader], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: true) + ); + + $writer->beginTransaction(); + + try { + $pdo->exec("INSERT INTO {$table} (id, value) VALUES (1, 'uncommitted')"); + + $withoutFlagStmt = $poolWithoutFlag->getReader()->pdo() + ->query("SELECT COUNT(*) AS c FROM {$table} WHERE id = 1"); + $withoutFlagResult = $withoutFlagStmt->fetch(PDO::FETCH_ASSOC); + $withoutFlagStmt->closeCursor(); + $this->assertEquals( + 0, + (int) $withoutFlagResult['c'], + 'Uncommitted write must not be visible via getReader() without the sticky-writer flag' + ); + + $withFlagStmt = $poolWithFlag->getReader()->pdo() + ->query("SELECT COUNT(*) AS c FROM {$table} WHERE id = 1"); + $withFlagResult = $withFlagStmt->fetch(PDO::FETCH_ASSOC); + $withFlagStmt->closeCursor(); + $this->assertEquals( + 1, + (int) $withFlagResult['c'], + 'Uncommitted write must be visible via getReader() with the sticky-writer flag' + ); + } finally { + // Roll back before the DROP TABLE cleanup below, on the assertion-failure path too - + // otherwise the cleanup would run inside a still-open transaction. + if ($writer->inTransaction()) { + $writer->rollback(); + } + } + } finally { + $pdo->exec("DROP TABLE IF EXISTS {$table}"); + } + } } diff --git a/tests/Unit/Config/ConnectionPoolConfigTest.php b/tests/Unit/Config/ConnectionPoolConfigTest.php index a23b0ae..e24e2c1 100644 --- a/tests/Unit/Config/ConnectionPoolConfigTest.php +++ b/tests/Unit/Config/ConnectionPoolConfigTest.php @@ -18,6 +18,18 @@ public function testDefaultValues(): void $this->assertEquals(30, $config->healthCheckCacheTtl); $this->assertEquals(0, $config->healthCheckNegativeCacheTtl); $this->assertEquals(ConnectionPoolConfig::STRATEGY_ROUND_ROBIN, $config->loadBalancingStrategy); + $this->assertFalse($config->stickyWriterDuringTransaction); + } + + /** + * U1: stickyWriterDuringTransaction defaults to false — bestand darf ohne Opt-in + * keine Verhaltensaenderung erfahren. + */ + public function testStickyWriterDuringTransactionDefaultsToFalse(): void + { + $config = new ConnectionPoolConfig(); + + $this->assertFalse($config->stickyWriterDuringTransaction); } public function testCustomValues(): void @@ -27,12 +39,14 @@ public function testCustomValues(): void healthCheckCacheTtl: 60, healthCheckNegativeCacheTtl: 10, loadBalancingStrategy: ConnectionPoolConfig::STRATEGY_RANDOM, + stickyWriterDuringTransaction: true, ); $this->assertFalse($config->validateConnections); $this->assertEquals(60, $config->healthCheckCacheTtl); $this->assertEquals(10, $config->healthCheckNegativeCacheTtl); $this->assertEquals(ConnectionPoolConfig::STRATEGY_RANDOM, $config->loadBalancingStrategy); + $this->assertTrue($config->stickyWriterDuringTransaction); } public function testNegativeHealthCheckTtlThrowsException(): void @@ -94,6 +108,7 @@ public function testPropertiesAreReadonly(): void 'healthCheckCacheTtl', 'healthCheckNegativeCacheTtl', 'loadBalancingStrategy', + 'stickyWriterDuringTransaction', ]; foreach ($properties as $propertyName) { diff --git a/tests/Unit/ConnectionPoolTest.php b/tests/Unit/ConnectionPoolTest.php new file mode 100644 index 0000000..f4077f9 --- /dev/null +++ b/tests/Unit/ConnectionPoolTest.php @@ -0,0 +1,174 @@ +factory = new ConnectionFactory(); + } + + /** + * U2: Flag aus + offene Tx -> Routing unveraendert (Reader kommt). + */ + public function testGetReaderIgnoresOpenWriterTransactionWhenFlagDisabled(): void + { + $writer = $this->factory->sqlite(); + $reader = $this->factory->sqlite(); + + $pool = new ConnectionPool( + writer: $writer, + readers: [$reader], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: false) + ); + + $writer->beginTransaction(); + + $this->assertSame($reader, $pool->getReader()); + + $writer->rollback(); + } + + /** + * U3: Flag an + offene Tx -> getReader() liefert dieselbe Instanz wie getWriter(). + */ + public function testGetReaderReturnsWriterInstanceWhenStickyFlagEnabledDuringOpenTransaction(): void + { + $writer = $this->factory->sqlite(); + $reader = $this->factory->sqlite(); + + $pool = new ConnectionPool( + writer: $writer, + readers: [$reader], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: true) + ); + + $writer->beginTransaction(); + + $this->assertSame($pool->getWriter(), $pool->getReader()); + $this->assertNotSame($reader, $pool->getReader()); + + $writer->rollback(); + } + + /** + * U4 (commit end): Flag an, Tx committed -> Routing kehrt zu Round-Robin zurueck. + */ + public function testStickyWriterRoutingEndsAfterCommit(): void + { + $writer = $this->factory->sqlite(); + $reader = $this->factory->sqlite(); + + $pool = new ConnectionPool( + writer: $writer, + readers: [$reader], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: true) + ); + + $writer->beginTransaction(); + $this->assertSame($writer, $pool->getReader()); + + $writer->commit(); + + $this->assertSame($reader, $pool->getReader()); + } + + /** + * U4 (rollback end): Flag an, Tx rolled back -> Routing kehrt zu Round-Robin zurueck. + */ + public function testStickyWriterRoutingEndsAfterRollback(): void + { + $writer = $this->factory->sqlite(); + $reader = $this->factory->sqlite(); + + $pool = new ConnectionPool( + writer: $writer, + readers: [$reader], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: true) + ); + + $writer->beginTransaction(); + $this->assertSame($writer, $pool->getReader()); + + $writer->rollback(); + + $this->assertSame($reader, $pool->getReader()); + } + + /** + * U5: Flag an, keine Tx -> normales Routing (Reader kommt). + */ + public function testGetReaderUsesNormalRoutingWhenStickyFlagEnabledButNoOpenTransaction(): void + { + $writer = $this->factory->sqlite(); + $reader = $this->factory->sqlite(); + + $pool = new ConnectionPool( + writer: $writer, + readers: [$reader], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: true) + ); + + $this->assertSame($reader, $pool->getReader()); + } + + /** + * U6: Flag an + leere Reader-Liste -> Verhalten wie bisher (Writer-Fallback), + * unabhaengig davon, ob eine Tx offen ist. + */ + public function testStickyFlagWithEmptyReaderListStillFallsBackToWriter(): void + { + $writer = $this->factory->sqlite(); + + $pool = new ConnectionPool( + writer: $writer, + readers: [], + config: new ConnectionPoolConfig(validateConnections: false, stickyWriterDuringTransaction: true) + ); + + $this->assertSame($writer, $pool->getReader()); + + $writer->beginTransaction(); + $this->assertSame($writer, $pool->getReader()); + $writer->rollback(); + } + + /** + * U7: Flag an + offene Tx + Writer unhealthy -> dieselbe RuntimeException wie getWriter(). + */ + public function testGetReaderThrowsSameExceptionAsGetWriterWhenWriterUnhealthyDuringStickyTransaction(): void + { + $writer = $this->createMock(DbConnectionInterface::class); + $writer->method('inTransaction')->willReturn(true); + $writer->method('pdo')->willThrowException(new RuntimeException('connection lost')); + + $pool = new ConnectionPool( + writer: $writer, + config: new ConnectionPoolConfig(validateConnections: true, stickyWriterDuringTransaction: true) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Writer connection health check failed'); + + $pool->getReader(); + } +} From bf0e6395b41b8a1c3f309d4176275fa53a937ff8 Mon Sep 17 00:00:00 2001 From: Headgent Development Date: Mon, 10 Aug 2026 09:07:13 +0200 Subject: [PATCH 2/2] docs(dbconnection): Transaction-sticky-Reads in README und SKILL.md dokumentiert Beschreibt Config-Flag stickyWriterDuringTransaction, Health-Semantik, getReaders()-Topologie-Auskunft und die zwei Grenzen (Repository-Caching, Kernel-Wiring) laut ZUG5-Baustein2-Plan Phase 2. Claude-Session: https://claude.ai/code/session_01B3myqC1R5NcWWMHxDh6zBH --- .claude/skills/adapter-dbconnection/SKILL.md | 8 ++++ README.md | 47 ++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/.claude/skills/adapter-dbconnection/SKILL.md b/.claude/skills/adapter-dbconnection/SKILL.md index 8809eca..094be96 100644 --- a/.claude/skills/adapter-dbconnection/SKILL.md +++ b/.claude/skills/adapter-dbconnection/SKILL.md @@ -89,6 +89,14 @@ $pool->resetStats(); Load balancing strategies: `STRATEGY_ROUND_ROBIN` (default), `STRATEGY_RANDOM`. +**Transaction-sticky reads:** `ConnectionPoolConfig(stickyWriterDuringTransaction: true)` (default +`false`) makes `getReader()` return the writer while `$writer->inTransaction()` is true — same +health check as `getWriter()`, throws the same `RuntimeException` if unhealthy. `getReaders()`/ +`getReaderCount()` still report the configured reader topology, not the per-call routing decision. +Two limits: consumers that cache a connection once per instance must fetch it inside the +transaction; the kernel bootstrap does not yet pass a config through, so the flag has no effect +on the default application bootstrap path yet. + ## INTERFACES (JardisSupport\Contract\DbConnection) | Interface | Key methods | |-----------|-------------| diff --git a/README.md b/README.md index 639a862..dd9664b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A PDO connection pool with read/write splitting for PHP — round-robin load bal ## Features - **Read/Write Splitting** — Route writes to the primary and reads to replicas automatically +- **Transaction-Sticky Reads** — Opt-in flag routes `getReader()` to the writer while its transaction is open, so in-transaction reads see the uncommitted state - **Round-Robin Load Balancing** — Distributes read queries evenly across all configured readers - **Health Checks** — `SELECT 1` validation with positive and negative result caching - **Transaction Support** — `beginTransaction()`, `commit()`, `rollback()`, `inTransaction()` on every connection @@ -96,6 +97,52 @@ try { $legacy = $factory->fromPdo($existingPdo); ``` +### Transaction-sticky reads + +While the writer has an open transaction, `getReader()` can return the writer instead of an +independent reader — so reads inside a transaction see the same uncommitted state the +transaction is writing, instead of stale data from a replica. This matters for logic that reads +and writes within one transaction boundary (e.g. a rule check that must decide on the state the +transaction itself just changed): + +```php +$pool = new ConnectionPool( + writer: $factory->mysql('primary.db', 'user', 'secret', 'mydb'), + readers: [ + $factory->mysql('replica1.db', 'user', 'secret', 'mydb'), + ], + config: new ConnectionPoolConfig(stickyWriterDuringTransaction: true) +); + +$pool->getWriter()->beginTransaction(); +$pool->getWriter()->pdo()->exec('UPDATE accounts SET balance = balance - 100 WHERE id = 1'); + +// With the flag on and the writer's transaction still open, getReader() returns the writer — +// this SELECT sees the uncommitted balance change above, not the replica's stale value. +$balance = $pool->getReader()->pdo()->query('SELECT balance FROM accounts WHERE id = 1')->fetch(); + +$pool->getWriter()->commit(); +// Transaction closed — getReader() is back to normal replica routing. +``` + +The flag is opt-in and defaults to `false`: without it, `getReader()` behaves exactly as before, +transaction or not. When the sticky path is active, the writer goes through the same health +check as `getWriter()` — an unhealthy writer during an open transaction throws the same +`RuntimeException` rather than silently falling back to a reader and breaking the transaction's +consistency. + +`getReaders()` and `getReaderCount()` are unaffected by the flag — they report the pool's +configured reader **topology** (how many replicas exist, which ones), not the routing decision +of an individual `getReader()` call. + +**Two honest limits:** +- If a consumer caches a connection once per instance (e.g. a repository that resolves its + reader on first use and reuses it) rather than calling `getReader()` on every read, it must + fetch the connection *inside* the transaction for the sticky binding to take effect. +- The Jardis kernel bootstrap does not yet pass a `ConnectionPoolConfig` through when building + the pool — until that wiring is updated, the flag has no effect on the generated + application's default bootstrap path. + ## PDO Connection Options All driver factory methods accept an `options` array passed directly to the PDO constructor. This is useful for long-running processes (RoadRunner, Swoole, FrankenPHP) where persistent connections avoid reconnect overhead per request: