Skip to content

fix(database): drop poisoned pooled connection on any lost-connection error (DAT-1904)#921

Closed
abnegate wants to merge 1 commit into
mainfrom
fix/drop-poisoned-connection-on-lost-error
Closed

fix(database): drop poisoned pooled connection on any lost-connection error (DAT-1904)#921
abnegate wants to merge 1 commit into
mainfrom
fix/drop-poisoned-connection-on-lost-error

Conversation

@abnegate

@abnegate abnegate commented Jul 16, 2026

Copy link
Copy Markdown
Member

Problem (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 the execute()-outside-a-transaction case. Every other lost-connection error was rethrown while the underlying PDO connection was left untouched and reclaimed to the pool as-is:

  • fetch() / fetchAll() / closeCursor() (the fetch phase of every read, e.g. SQL::getDocument() at execute()fetchAll()closeCursor(), and SQL::find())
  • execute() inside a transaction

A connection lost mid-result is wire-desynced: a partial or stale result frame can remain buffered on the socket (classically a client-side read timeout while the server keeps streaming the result set). Returning that connection to the pool poisons it.

Blast radius

The next coroutine to pop the poisoned connection runs its own query and reads the leftover frame first:

Because Adapter::withTransaction() replays its callback up to 2× and calls getPDO() afresh on each attempt, a reclaimed poisoned connection can be handed straight back into the replay.

Fix

Extend the recovery in PDOStatement::__call() so that on any detected lost-connection error (same detection as today, Connection::hasError() / DetectsLostConnections) that is not the execute()-outside-transaction fast path, we call PDO::reconnect() to discard the wire-desynced socket before rethrowing. This guarantees the connection reclaimed to the pool is always clean.

  • The existing transparent execute()-outside-transaction retry (reprepare + replay bindings + re-execute) is unchanged.
  • Non-connection errors (syntax errors, constraint violations, business logic) still rethrow untouched — a healthy connection is never thrashed on ordinary query errors.
  • Callers / withTransaction still see the rethrow and roll back / replay on the now-fresh connection. In the server-genuinely-down case, reconnect() may itself throw Connection refused, which is already in the lost-connection needle set, so callers classify it identically.

Why the fetch phase must reconnect rather than retry

A fetch cannot be retried in place: re-running fetch() on a freshly prepared statement would read no rows. The only safe recovery is to drop the desynced socket and let the caller replay the whole operation on a clean connection.

Tests

tests/unit/PDOStatementTest.php (mock-based, no live DB):

  • testFetchPhaseLostConnectionReconnectsBeforeRethrowing (new, key regression) — a lost connection during fetchAll() invokes reconnect() exactly once and does not re-prepare, then the original exception propagates. Fails without this change (reconnect called 0×).
  • testExecuteInsideTransactionReconnectsThenRethrows (updated) — execute() losing the connection inside a transaction now drops the socket before rethrowing. Fails without this change.
  • testDoesNotReconnectForNonExecuteNonConnectionError (new) — a non-connection error during fetchAll() must not reconnect (no pool thrashing).
  • The execute()-outside-transaction reprepare/replay tests are unchanged and still pass.
OK (9 tests, 35 assertions)   # with the fix
Tests: 3, Assertions: 10, Failures: 2   # the two reconnect-asserting tests fail without the fix

vendor/bin/pint --test passes on the changed files. (The local full-suite run hits a pre-existing, unrelated Utopia\Cache\Feature\Leasable fatal in WithCacheLeaseTest.php from a stale local utopia-php/cache install — present on clean main; CI's fresh install is unaffected.)

Relationship to other work

  • Complements Fix: CLOUD-3N2A #898 (CLOUD-3N2A): that hardens the stale-transaction rollback side in startTransaction(); this hardens the poisoned-socket side so the connection is never reclaimed desynced. Different files, no overlap.
  • Defense in depth: fix: recover failed pool connections pools#33 destroys/recreates unhealthy connections on reclaim at the pool layer. This DB-layer fix makes the reclaimed PDO connection fresh regardless.
  • Downstream containment: appwrite-labs/cloud#4827.

Refs: #895, #896, appwrite-labs/cloud#4827.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of lost database connections during statement execution and result fetching.
    • Automatically retries eligible executions when no transaction is active.
    • Reconnects before reporting unrecoverable connection errors, helping prevent unstable connections from being reused.
    • Preserves immediate error reporting for non-connection-related failures.
  • Tests

    • Expanded coverage for connection loss during transactions, fetching, and statement execution.

… 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>
Copilot AI review requested due to automatic review settings July 16, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4a1c303e-a1da-410a-b84d-d767d0e7fe39

📥 Commits

Reviewing files that changed from the base of the PR and between d5ebecc and c5da613.

📒 Files selected for processing (2)
  • src/Database/PDOStatement.php
  • tests/unit/PDOStatementTest.php

📝 Walkthrough

Walkthrough

PDOStatement's lost-connection handling is refined so retry is attempted only for execute() outside transactions; other recoverable cases (fetch/closeCursor, or execute() inside a transaction) now explicitly reconnect before rethrowing to discard poisoned connections. Corresponding unit tests were updated to match this behavior.

Changes

PDOStatement Reconnect Logic

Layer / File(s) Summary
Doc comment and __call() error handling rework
src/Database/PDOStatement.php
Doc comment clarifies retry scope; __call() now rethrows non-lost-connection errors immediately, retries execute() outside transactions via reprepare, and calls reconnect() before rethrowing for other recoverable cases.
Test updates for reconnect-then-rethrow behavior
tests/unit/PDOStatementTest.php
Transaction test renamed to expect reconnect() call; new tests verify fetchAll() lost-connection triggers reconnect() and non-connection errors skip reconnect()/prepareNative(); obsolete non-reconnect test removed.

Estimated code review effort: 2 (Simple) | ~12 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant PDOStatement
  participant PDO

  Caller->>PDOStatement: __call(method, args)
  PDOStatement->>PDO: execute()/fetch()
  PDO-->>PDOStatement: throws error

  alt not a lost-connection error
    PDOStatement-->>Caller: rethrow immediately
  else execute() outside transaction
    PDOStatement->>PDOStatement: log warning
    PDOStatement->>PDO: reprepare()
    PDOStatement->>PDO: retry original call
    PDO-->>Caller: return result
  else fetch/closeCursor or execute() in transaction
    PDOStatement->>PDO: reconnect()
    PDOStatement-->>Caller: rethrow error
  end
Loading

Possibly related PRs

  • utopia-php/database#706: Implements transaction-aware lost-connection handling with reconnect-and-retry in PDO.php, related to the reconnect logic added in PDOStatement.php.
  • utopia-php/database#896: Introduced the original PDOStatement error-handling and test expectations that this PR refines in the same files.

Suggested reviewers: copilot

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/drop-poisoned-connection-on-lost-error

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a pool-poisoning bug where a lost connection during the fetch phase (fetchAll, fetch, closeCursor) or during execute() inside a transaction was rethrown without cleaning up the desynced socket, allowing the wire-desynced PDO connection to be reclaimed to the pool and bleed leftover result frames into the next borrower's query.

  • PDOStatement::__call() is restructured: any detected lost-connection error that cannot be transparently retried (non-execute methods, or execute inside a transaction) now calls $this->pdo->reconnect() before rethrowing, guaranteeing the connection returned to the pool is clean.
  • Tests cover the three new/changed behaviors: fetch-phase reconnect, in-transaction execute reconnect, and no-reconnect for ordinary (non-connection) errors.

Confidence Score: 4/5

Safe to merge — the logic restructuring is correct and all new code paths are covered by unit tests.

The new reconnect-then-rethrow branch drops the desynced socket silently with no log output, unlike every other connection-recovery path in both PDOStatement and PDO, which could slow incident investigation in production.

src/Database/PDOStatement.php — the new reconnect-then-rethrow branch (lines 138–140) emits no warning before calling reconnect.

Important Files Changed

Filename Overview
src/Database/PDOStatement.php Extends lost-connection recovery to discard (reconnect) the desynced socket before rethrowing on any connection error that cannot be retried in place; logic restructuring is correct, though the new path emits no warning log unlike the existing execute-outside-transaction path.
tests/unit/PDOStatementTest.php Adds/updates tests for fetch-phase reconnect, in-transaction reconnect, and non-connection error no-reconnect; covers the key new behaviors with clear mock assertions.

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/Database/PDOStatement.php:138-140
**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.

Reviews (1): Last reviewed commit: "fix(database): drop poisoned pooled conn..." | Re-trigger Greptile

Comment on lines +138 to +140
$this->pdo->reconnect();

throw $e;

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

@abnegate

Copy link
Copy Markdown
Member Author

Closing per course-correction. An adapter-level reconnect-on-fetch-error heals one trigger of a poisoned pooled connection, not the root cause. DAT-1904's root cause is at the connection-pool / lifecycle layer: a pooled connection can be handed to the next borrower carrying leftover state (an unconsumed result frame, or a leaked transaction counter). The correct invariant — a connection that experienced any error or still carries pending state is never reused; it is reset or destroyed before the next checkout — must be enforced there, independent of which trigger tainted it. Superseded by the pool/connection-lifecycle fix (see utopia-php/pools#33 lineage, #895/#896).

@abnegate abnegate closed this Jul 16, 2026
@abnegate abnegate deleted the fix/drop-poisoned-connection-on-lost-error branch July 16, 2026 13:58
@abnegate

Copy link
Copy Markdown
Member Author

Confirmed redundant after tracing the runtime path end-to-end — recording the evidence:

The production DB adapter Utopia\Database\Adapter\Pool routes every operation through $this->pool->use(fn(Adapter $adapter) => $adapter->{$method}(...)), and withTransaction() wraps the whole transaction in a single use(). When fetchAll() (or a mid-transaction write) raises a lost-connection error, it propagates out of the use() callback, so Utopia\Pools\Pool::use() sets $failed = true and calls release($connection, failed: true). That path (pools#33) runs recover(), which finds reconnect() on the pooled Adapter and calls it — SQL::reconnect() opens a fresh PDO (discarding the wire-desynced socket) and resets inTransaction = 0 (the leaked-counter vector), then reclaims the clean connection; if reconnect() throws it destroy()s instead. Either way the poisoned connection is never handed to the next borrower.

So the pool already reconnects the exact socket this PR reconnects, on the same error, one layer down — for both the result-frame and transaction-counter taint, and for every borrow-error path (not just fetch). An adapter-level reconnect here is redundant. Root fix: utopia-php/pools#33.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants