Skip to content

Commit c5da613

Browse files
abnegateclaude
andcommitted
fix(database): drop poisoned pooled connection on any lost-connection error (DAT-1904)
A pooled DB connection is checked out per operation from a worker-shared, per-host pool. The reconnect wrapper in PDOStatement::__call() only self-healed a lost connection for execute() outside a transaction. Every other lost-connection error (fetch, fetchAll, closeCursor, or execute inside a transaction) was rethrown while the underlying connection was left untouched and reclaimed to the pool as-is. A connection lost mid-result is wire-desynced: a partial or stale result frame can remain buffered on the socket. Returning that connection to the pool poisons it. The next coroutine to pop it runs its own query and reads the leftover frame instead, returning a different row (cross-document, and cross-tenant on shared shards) and corrupting writes (the "no active transaction" class). The fetch phase must reconnect because it cannot be retried in place: re-running fetch on a fresh statement would read no rows, so the only safe recovery is to discard the desynced socket and let the caller replay. This change extends the recovery so that on any detected lost-connection error outside the execute-outside-transaction fast path, PDOStatement::__call() calls PDO::reconnect() to drop the socket before rethrowing. The existing transparent retry is unchanged. Non-connection errors still rethrow untouched, so a healthy connection is never thrashed on ordinary query errors. Callers and Adapter::withTransaction still see the rethrow and roll back or replay on the now fresh connection. Adds a regression test proving reconnect() is invoked before the exception propagates for a fetch-phase lost connection and for execute inside a transaction, and is not invoked for a non-connection error. Refs: #895, #896, appwrite-labs/cloud#4827 (downstream containment), utopia-php/pools#33 (pool-layer defense in depth) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d5ebecc commit c5da613

2 files changed

Lines changed: 65 additions & 20 deletions

File tree

‎src/Database/PDOStatement.php‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,19 @@
1010
* against the fresh connection, previously bound parameters/columns/attributes
1111
* are replayed, and the failed execute() is retried.
1212
*
13-
* Recovery is attempted only for execute(), and only outside a transaction:
14-
* re-running any other method (fetch, rowCount, ...) without a fresh execute
15-
* would return data from an unexecuted statement, and a connection cannot be
16-
* healed in place mid-transaction (the uncommitted state is gone, so the call
17-
* is rethrown for Adapter::withTransaction to roll back and replay).
13+
* Transparent retry is attempted only for execute(), and only outside a
14+
* transaction: re-running any other method (fetch, rowCount, ...) without a
15+
* fresh execute would return data from an unexecuted statement, and a
16+
* connection cannot be healed in place mid-transaction (the uncommitted state
17+
* is gone, so the call is rethrown for Adapter::withTransaction to roll back
18+
* and replay).
19+
*
20+
* In every lost-connection case that cannot be retried, the poisoned connection
21+
* is still discarded (reconnect) before the rethrow: the failed fetch/execute
22+
* leaves the socket wire-desynced, and a pooled connection reclaimed with a
23+
* partial result frame still buffered would bleed that frame into the next
24+
* borrower's query. Reconnecting guarantees the connection returned to the pool
25+
* is always clean.
1826
*
1927
* @mixin \PDOStatement
2028
* @implements \IteratorAggregate<int, mixed>
@@ -108,20 +116,28 @@ public function __call(string $method, array $args): mixed
108116
try {
109117
return $this->statement->{$method}(...$args);
110118
} catch (\Throwable $e) {
111-
if (
112-
\strcasecmp($method, 'execute') !== 0
113-
|| $this->pdo->inTransaction()
114-
|| !Connection::hasError($e)
115-
) {
119+
if (!Connection::hasError($e)) {
116120
throw $e;
117121
}
118122

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

122-
$this->reprepare();
127+
$this->reprepare();
123128

124-
return $this->statement->{$method}(...$args);
129+
return $this->statement->{$method}(...$args);
130+
}
131+
132+
// A lost connection during fetch/fetchAll/closeCursor, or during
133+
// execute inside a transaction, cannot be retried in place -- but the
134+
// socket is now wire-desynced and may still hold a partial result
135+
// frame. Drop the connection so it can never be reclaimed to the pool
136+
// and bleed that frame into the next borrower's query; the rethrow
137+
// still lets withTransaction roll back and replay on a fresh one.
138+
$this->pdo->reconnect();
139+
140+
throw $e;
125141
}
126142
}
127143

‎tests/unit/PDOStatementTest.php‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ public function testExecuteReconnectsRePreparesAndReplaysWhenNotInTransaction():
6969
$this->assertTrue($statement->execute());
7070
}
7171

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

7880
$statement = $this->statementMock();
@@ -133,23 +135,50 @@ public function testIsIterableAndDelegatesIterationToTheStatement(): void
133135
$this->assertSame($statement, $wrapper->getIterator());
134136
}
135137

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

142149
$statement = $this->statementMock();
143150
$statement->expects($this->once())
144-
->method('fetch')
151+
->method('fetchAll')
145152
->willThrowException(new PDOException('server has gone away'));
146153

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

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

152-
$wrapper->fetch();
159+
$wrapper->fetchAll();
160+
}
161+
162+
public function testDoesNotReconnectForNonExecuteNonConnectionError(): void
163+
{
164+
// A business-logic error (not a lost connection) leaves the pooled
165+
// connection untouched: reconnecting a healthy connection would thrash the
166+
// pool on every ordinary query error.
167+
$pdo = $this->pdoMock(inTransaction: false);
168+
$pdo->expects($this->never())->method('reconnect');
169+
$pdo->expects($this->never())->method('prepareNative');
170+
171+
$statement = $this->statementMock();
172+
$statement->expects($this->once())
173+
->method('fetchAll')
174+
->willThrowException(new PDOException('SQLSTATE[42000]: Syntax error'));
175+
176+
$wrapper = new PDOStatement($pdo, $statement, 'SELECT 1');
177+
178+
$this->expectException(PDOException::class);
179+
$this->expectExceptionMessage('Syntax error');
180+
181+
$wrapper->fetchAll();
153182
}
154183

155184
public function testBindParamReplaysCurrentValueAfterReconnect(): void

0 commit comments

Comments
 (0)