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
8 changes: 8 additions & 0 deletions .claude/skills/adapter-dbconnection/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-----------|-------------|
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/Config/ConnectionPoolConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@
* @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(
public bool $validateConnections = true,
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');
Expand Down
19 changes: 18 additions & 1 deletion src/ConnectionPool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, true> $tried */
$tried = [];
Expand Down
116 changes: 116 additions & 0 deletions tests/Integration/ConnectionPoolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}
}
15 changes: 15 additions & 0 deletions tests/Unit/Config/ConnectionPoolConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -94,6 +108,7 @@ public function testPropertiesAreReadonly(): void
'healthCheckCacheTtl',
'healthCheckNegativeCacheTtl',
'loadBalancingStrategy',
'stickyWriterDuringTransaction',
];

foreach ($properties as $propertyName) {
Expand Down
Loading