Skip to content
Closed
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
44 changes: 30 additions & 14 deletions src/Database/PDOStatement.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@
* against the fresh connection, previously bound parameters/columns/attributes
* are replayed, and the failed execute() is retried.
*
* Recovery is attempted only for execute(), and only outside a transaction:
* re-running any other method (fetch, rowCount, ...) without a fresh execute
* would return data from an unexecuted statement, and a connection cannot be
* healed in place mid-transaction (the uncommitted state is gone, so the call
* is rethrown for Adapter::withTransaction to roll back and replay).
* Transparent retry is attempted only for execute(), and only outside a
* transaction: re-running any other method (fetch, rowCount, ...) without a
* fresh execute would return data from an unexecuted statement, and a
* connection cannot be healed in place mid-transaction (the uncommitted state
* is gone, so the call is rethrown for Adapter::withTransaction to roll back
* and replay).
*
* In every lost-connection case that cannot be retried, the poisoned connection
* is still discarded (reconnect) before the rethrow: the failed fetch/execute
* leaves the socket wire-desynced, and a pooled connection reclaimed with a
* partial result frame still buffered would bleed that frame into the next
* borrower's query. Reconnecting guarantees the connection returned to the pool
* is always clean.
*
* @mixin \PDOStatement
* @implements \IteratorAggregate<int, mixed>
Expand Down Expand Up @@ -108,20 +116,28 @@ public function __call(string $method, array $args): mixed
try {
return $this->statement->{$method}(...$args);
} catch (\Throwable $e) {
if (
\strcasecmp($method, 'execute') !== 0
|| $this->pdo->inTransaction()
|| !Connection::hasError($e)
) {
if (!Connection::hasError($e)) {
throw $e;
}

Console::warning('[Database] ' . $e->getMessage());
Console::warning('[Database] Lost connection detected. Re-preparing statement...');
if (\strcasecmp($method, 'execute') === 0 && !$this->pdo->inTransaction()) {
Console::warning('[Database] ' . $e->getMessage());
Console::warning('[Database] Lost connection detected. Re-preparing statement...');

$this->reprepare();
$this->reprepare();

return $this->statement->{$method}(...$args);
return $this->statement->{$method}(...$args);
}

// A lost connection during fetch/fetchAll/closeCursor, or during
// execute inside a transaction, cannot be retried in place -- but the
// socket is now wire-desynced and may still hold a partial result
// frame. Drop the connection so it can never be reclaimed to the pool
// and bleed that frame into the next borrower's query; the rethrow
// still lets withTransaction roll back and replay on a fresh one.
$this->pdo->reconnect();

throw $e;
Comment on lines +138 to +140

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent reconnect makes fetch-phase drops invisible in logs

When a fetch-phase or in-transaction connection loss triggers the reconnect-then-rethrow path, no warning is emitted. The execute-outside-transaction path logs both the error message and "Lost connection detected" before acting, and PDO::__call() also logs before reconnecting in every case it handles. A fetch-phase drop silently discards the socket, giving operators no signal in the logs that a pooled connection was cleaned up. Consider adding a Console::warning pair (the original error message + a short note) before the reconnect() call, mirroring the existing pattern.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/PDOStatement.php
Line: 138-140

Comment:
**Silent reconnect makes fetch-phase drops invisible in logs**

When a fetch-phase or in-transaction connection loss triggers the reconnect-then-rethrow path, no warning is emitted. The execute-outside-transaction path logs both the error message and "Lost connection detected" before acting, and `PDO::__call()` also logs before reconnecting in every case it handles. A fetch-phase drop silently discards the socket, giving operators no signal in the logs that a pooled connection was cleaned up. Consider adding a `Console::warning` pair (the original error message + a short note) before the `reconnect()` call, mirroring the existing pattern.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

}
}

Expand Down
41 changes: 35 additions & 6 deletions tests/unit/PDOStatementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ public function testExecuteReconnectsRePreparesAndReplaysWhenNotInTransaction():
$this->assertTrue($statement->execute());
}

public function testExecuteRethrowsAndDoesNotReconnectInsideTransaction(): void
public function testExecuteInsideTransactionReconnectsThenRethrows(): void
{
$pdo = $this->pdoMock(inTransaction: true);
$pdo->expects($this->never())->method('reconnect');
// Cannot retry in place (the uncommitted state is gone), but the poisoned
// socket must still be dropped so it is not reclaimed to the pool.
$pdo->expects($this->once())->method('reconnect');
$pdo->expects($this->never())->method('prepareNative');

$statement = $this->statementMock();
Expand Down Expand Up @@ -133,23 +135,50 @@ public function testIsIterableAndDelegatesIterationToTheStatement(): void
$this->assertSame($statement, $wrapper->getIterator());
}

public function testDoesNotReconnectForNonExecuteMethods(): void
public function testFetchPhaseLostConnectionReconnectsBeforeRethrowing(): void
{
// DAT-1904: a lost connection during the fetch phase leaves the socket
// wire-desynced (a partial result frame may remain buffered). The poisoned
// connection must be dropped (reconnect) before the rethrow so it can never
// be reclaimed to the pool and bled into the next borrower's query.
$pdo = $this->pdoMock(inTransaction: false);
$pdo->expects($this->never())->method('reconnect');
$pdo->expects($this->once())->method('reconnect');
// Not re-prepared/retried in place: a fresh fetch would read no rows.
$pdo->expects($this->never())->method('prepareNative');

$statement = $this->statementMock();
$statement->expects($this->once())
->method('fetch')
->method('fetchAll')
->willThrowException(new PDOException('server has gone away'));

$wrapper = new PDOStatement($pdo, $statement, 'SELECT 1');

$this->expectException(PDOException::class);
$this->expectExceptionMessage('server has gone away');

$wrapper->fetch();
$wrapper->fetchAll();
}

public function testDoesNotReconnectForNonExecuteNonConnectionError(): void
{
// A business-logic error (not a lost connection) leaves the pooled
// connection untouched: reconnecting a healthy connection would thrash the
// pool on every ordinary query error.
$pdo = $this->pdoMock(inTransaction: false);
$pdo->expects($this->never())->method('reconnect');
$pdo->expects($this->never())->method('prepareNative');

$statement = $this->statementMock();
$statement->expects($this->once())
->method('fetchAll')
->willThrowException(new PDOException('SQLSTATE[42000]: Syntax error'));

$wrapper = new PDOStatement($pdo, $statement, 'SELECT 1');

$this->expectException(PDOException::class);
$this->expectExceptionMessage('Syntax error');

$wrapper->fetchAll();
}

public function testBindParamReplaysCurrentValueAfterReconnect(): void
Expand Down
Loading