Skip to content

refactor(transaction): own connection recovery in the adapter, drop Swoole PDOProxy dependency#896

Merged
abnegate merged 1 commit into
mainfrom
fix/adapter-owned-connection-recovery
Jun 18, 2026
Merged

refactor(transaction): own connection recovery in the adapter, drop Swoole PDOProxy dependency#896
abnegate merged 1 commit into
mainfrom
fix/adapter-owned-connection-recovery

Conversation

@abnegate

@abnegate abnegate commented Jun 18, 2026

Copy link
Copy Markdown
Member

Stacked on #895. Diff against fix/start-transaction-desynced-rollback.

Problem

#895 stops the symptom of the nyc3 write outage, but the root cause is upstream of this library: consumers wrap connections in Swoole\Database\PDOProxy, which keeps its own inTransaction counter that is incremented on beginTransaction, decremented only on a successful commit/rollback, not reset by reconnect(), and not reset on pool checkin (utopia-php/pools never calls the proxy's reset() — that hook only fires under Swoole's own PDOPool).

A connection lost mid-transaction leaks the counter and poisons the pooled connection: every later startTransaction cleanup probe trusts the stale getPDO()->inTransaction(), issues a rollBack the real connection no longer holds, and fails with There is no active transaction — across all projects, until pods restart. (nyc3: ~110k errors over ~1.5h, writes only.)

This can't be fixed by patching the cleanup probe while a second, unreconciled source of truth for "am I in a transaction" lives in the proxy. The fix removes the need for the proxy and makes the adapter own connection-loss recovery, with the real \PDO as the single source of truth.

What changed

  • PDOStatement (new) — wraps prepared statements. On a lost connection at execution time, when not in a transaction, it reconnects the owning PDO, re-prepares against the fresh connection, replays bound params/attributes/options, and retries. This restores the only genuinely load-bearing behaviour the Swoole PDOStatementProxy provided — reconnect-on-execute — and it's what heals a stale pooled connection at the prepare('ROLLBACK')->execute() probe that fronts startTransaction (that probe runs at inTransaction === 0). Inside a transaction it rethrows; the connection state is read from the real \PDO, so there is no separate counter to desync.
  • PDO::prepare() returns the wrapper; prepareNative() re-prepares a raw \PDOStatement for the wrapper. ERRMODE_EXCEPTION is defaulted in the constructor so the library no longer depends on the proxy/consumer to set it.
  • Replaced the Swoole\Database\PDOStatementProxy type hints in SQL/Postgres/SQLite with the new PDOStatement.

Connection-level calls (beginTransaction/rollBack/commit/exec) are already covered by the pre-existing Utopia\Database\PDO::__call reconnect, and Adapter::withTransaction's pre-existing retry loop replays the closure — so the mid-transaction-death path recovers by replay on the connection that __call reconnects. No change to withTransaction is needed.

Reviewed

Two independent reviews of this PR led to: removing a speculative withTransaction reconnect block that an earlier revision added — it was unreachable on the real SQL path (a dead-connection rollBack throws and is caught earlier in the loop) and, when reached (e.g. a nested transaction), would have discarded the outer BEGIN and committed the inner write standalone (silent partial commit). Recovery is correctly carried by PDOStatement re-prepare + PDO::__call, both unit-covered. Also threaded prepare() $options through re-prepare and tightened a @throws.

Known limitation (pre-existing, out of scope): connection loss inside a nested transaction is not safely recoverable by the retry loop in any version; this PR does not make it worse (it no longer adds a reconnect there).

Tests

PDOStatementTest (reconnect/re-prepare/replay outside a txn; rethrow inside; non-connection rethrow; forwarding), PDOTest (PDO::__call reconnect, wrapper return), SQLTransactionTest (desynced-cleanup recovery, MySQL + Postgres). phpunit (full unit suite), phpstan --level 7, pint green locally.

Recommended follow-up: an e2e test that severs a live MySQL/Postgres connection mid-transaction (KILL / wait_timeout) and asserts recovery — the recovery path is currently only unit-mocked.

Consumer follow-ups

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced database connection resilience with automatic reconnection and statement re-preparation when connections are lost during execution
    • Improved error handling with exceptions enabled by default for database operations
  • Internal Improvements

    • Updated database adapters for improved compatibility and stability

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@abnegate, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 4 minutes and 35 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 117659cb-dded-4f52-970b-d78419f9b29a

📥 Commits

Reviewing files that changed from the base of the PR and between 5a807d7 and 12c3bf1.

📒 Files selected for processing (7)
  • src/Database/Adapter/Postgres.php
  • src/Database/Adapter/SQL.php
  • src/Database/Adapter/SQLite.php
  • src/Database/PDO.php
  • src/Database/PDOStatement.php
  • tests/unit/PDOStatementTest.php
  • tests/unit/PDOTest.php
📝 Walkthrough

Walkthrough

Introduces Utopia\Database\PDOStatement, a wrapper that intercepts execution-time PDO failures, reconnects via PDO::reconnect(), re-prepares the statement using new PDO::prepareNative(), replays bindings and attributes, and retries. PDO::prepare() now returns the wrapped type. All three SQL adapters (SQL, Postgres, SQLite) drop their Swoole\Database\PDOStatementProxy dependency in favour of the new class.

Changes

PDOStatement Reconnect Wrapper

Layer / File(s) Summary
PDOStatement wrapper class
src/Database/PDOStatement.php
New class stores bound values/params/columns, fetch mode, and attributes; proxies \PDOStatement via magic methods; __call detects lost-connection errors and, when not in a transaction, calls reprepare() to reconnect, recreate, and replay the failed call; reprepare() applies all stored state to the fresh native statement.
PDO prepare and prepareNative methods
src/Database/PDO.php
Constructor ensures ERRMODE_EXCEPTION is set; new prepare() delegates to prepareNative() and wraps the result in PDOStatement; new prepareNative() calls the underlying \PDO::prepare() and throws on false.
Adapter type migration from Swoole to Utopia
src/Database/Adapter/SQL.php, src/Database/Adapter/Postgres.php, src/Database/Adapter/SQLite.php
Removes Swoole\Database\PDOStatementProxy imports; adds Utopia\Database\PDOStatement import; updates bindOperatorParams PHPDoc and $stmt union type in all three adapters.
Unit tests for PDOStatement and PDO prepare
tests/unit/PDOStatementTest.php, tests/unit/PDOTest.php
PDOStatementTest tests reconnect/replay on non-transaction failures, rethrow inside transactions, rethrow on non-connection errors, and call forwarding; PDOTest updated to assert prepare() returns a PDOStatement instance and getStatement() yields the underlying mock.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • utopia-php/database#706: Both PRs modify src/Database/PDO.php to handle lost-connection recovery and reconnect logic; this PR extends that pattern down to the statement level via the new PDOStatement wrapper.

Poem

🐇 Hoppity hop through broken pipes,
No Swoole proxy needed now!
We reconnect, rebind, and retry,
Our wrappers take a solemn vow.
Lost connections fear the hare —
For reprepare() is always there! 🔌

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main changes: refactoring connection recovery in the adapter and removing the Swoole PDOProxy dependency.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/adapter-owned-connection-recovery

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 and usage tips.

@abnegate abnegate force-pushed the fix/adapter-owned-connection-recovery branch from f2f8a74 to fa02875 Compare June 18, 2026 05:18
Base automatically changed from fix/start-transaction-desynced-rollback to main June 18, 2026 05:19
@abnegate abnegate marked this pull request as ready for review June 18, 2026 05:20
Copilot AI review requested due to automatic review settings June 18, 2026 05:20

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.

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

  • Adds a PDOStatement wrapper that can reconnect, re-prepare, replay bindings, and retry execution outside active transactions.
  • Updates PDO::prepare() to return the wrapper, adds prepareNative() for raw statement preparation, and defaults PDO error mode to exceptions.
  • Replaces Swoole statement proxy type hints in SQL adapters with the new wrapper type.
  • Expands unit coverage around statement recovery, prepare behavior, forwarding, and transaction cleanup recovery.

Confidence Score: 5/5

The changes are narrowly scoped to PDO connection recovery and statement wrapping, with no outstanding code issues identified.

The implementation is backed by targeted unit coverage for reconnect, re-prepare, binding replay, forwarding, and transaction cleanup recovery paths.

T-Rex T-Rex Logs

What T-Rex did

  • The base-native-statement run failed with a PDOException: server has gone away, with reconnects=0 and no second prepared statement.
  • The head-wrapper-statement run succeeded, with warning logs, status SUCCESS, reconnects=1, prepared preserving SQL/options, and second statement events replaying attribute/fetch mode/bindings including updated bindParam value new-ref-value before retry execute.
  • Before the transaction boundary test, the base run showed pdoStatementFileExists=no and result NO_PDOStatement_wrapper_available_on_this_revision.
  • After the transaction boundary test, the head run showed outcome=threw for both cases, with reconnects=0, prepares=0, originalExecutes=1.
  • Before the prepare/state check, the base prepare state showed prepare.class=PDOStatement, prepare.is_wrapper=no, prepareNative.exists=no, and adapters still contained PDOStatementProxy/Swoole\Database references.
  • After the prepare/state check, the environment updated to pdo.errmode=2 (PDO::ERRMODE_EXCEPTION=2), prepare.class=Utopia\Database\PDOStatement, prepareNative.class=PDOStatement with raw PDOStatement=yes and wrapper=no, adapters reported no PDOStatementProxy or Swoole\Database references, and both runs exited with code 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (6): Last reviewed commit: "refactor(transaction): own connection re..." | Re-trigger Greptile

Comment thread src/Database/PDOStatement.php Outdated
Comment thread src/Database/PDO.php Outdated
Comment thread src/Database/PDOStatement.php
Comment thread src/Database/PDOStatement.php
Comment thread src/Database/PDOStatement.php Outdated
Comment thread src/Database/PDOStatement.php Outdated
@abnegate abnegate force-pushed the fix/adapter-owned-connection-recovery branch from 3c35369 to 5a807d7 Compare June 18, 2026 06:05
abnegate added a commit to appwrite/appwrite that referenced this pull request Jun 18, 2026
…le PDOProxy

The Swoole PDOProxy keeps its own transaction counter that desyncs on a
mid-transaction connection loss and is never reset on reconnect or pool checkin,
poisoning pooled connections (every later startTransaction trusts the stale
counter and fails with "There is no active transaction" until restart).

utopia-php/database#896 moves connection-loss recovery into the adapter
(reconnect-on-execute via Utopia\Database\PDOStatement + withTransaction replay)
with the real PDO as the single source of truth, so the proxy is no longer
needed and the desync is structurally impossible.

- registers.php: wrap connections in Utopia\Database\PDO directly instead of
  Swoole\Database\PDOProxy.
- Set PDO::ATTR_ERRMODE => ERRMODE_EXCEPTION explicitly (previously enforced by
  the proxy; also defaulted by #896, set here for clarity).
- Require the #896 branch via a VCS repository until it is tagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/Database/PDO.php Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Database/PDO.php`:
- Around line 64-72: The prepareNative method lacks defensive reconnection logic
for when native prepares are used (ATTR_EMULATE_PREPARES => false). Although
current configurations enforce emulated prepares where connection loss surfaces
at execution time and is properly recovered, if native prepares are enabled in
the future, connection loss during the prepare call would bypass the recovery
mechanism in PDO::__call() and propagate uncaught. Add defensive reconnection
handling to prepareNative by checking for transaction context before attempting
to reconnect and retry the prepare operation, following the same recovery
pattern already implemented in PDOStatement::__call() for execution-time
failures.

In `@src/Database/PDOStatement.php`:
- Around line 87-101: The __call method unconditionally retries the statement
execution after a reconnect without verifying whether the first execution
already completed on the server, which can cause non-transactional write
operations to be applied twice and bypass adapter execution hooks like
statement_timeout. Gate the statement replay at line 101 to only retry for
idempotent operations or operations within an active transaction, or move the
retry orchestration to a higher-level layer that can ensure idempotency and
manage adapter session state. Modify the retry logic after reprepare() to check
the operation type or transaction context before re-executing the statement
method.
- Around line 131-142: The bindParam() and bindColumn() methods store variables
by value instead of by reference, causing stale copies to be used during
reprepare() rebinding. Fix this by storing references to the actual variables in
the $this->params and $this->columns arrays rather than copying their values.
Additionally, the bindColumn() method has an incorrect signature with nullable
parameter defaults (?int $type = null, ?int $maxLength = null) that do not match
PDO's native expectations; update these to use non-nullable defaults matching
PDO's actual signature (int $type = PDO::PARAM_STR, int $maxLength = 0).
Finally, when reprepare() loops through and re-binds these stored parameters and
columns, ensure the iteration maintains reference semantics so that caller-side
mutations and fetched values are properly reflected in the original caller's
variables.

In `@tests/unit/PDOStatementTest.php`:
- Around line 61-64: The mock expectation for prepareNative() in the test is
incomplete. Currently, the ->with() matcher only specifies the first argument
('SELECT :id'), but the actual reprepare() method invokes prepareNative() with
two arguments: the query string and an options parameter. Update the ->with()
expectation to match both arguments that are actually passed to prepareNative(),
ensuring the test accurately reflects the real method contract and prevents
brittleness from future changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 941a84a9-7122-4ee8-a97e-ebefde00b43f

📥 Commits

Reviewing files that changed from the base of the PR and between acc0c1c and 5a807d7.

📒 Files selected for processing (7)
  • src/Database/Adapter/Postgres.php
  • src/Database/Adapter/SQL.php
  • src/Database/Adapter/SQLite.php
  • src/Database/PDO.php
  • src/Database/PDOStatement.php
  • tests/unit/PDOStatementTest.php
  • tests/unit/PDOTest.php

Comment thread src/Database/PDO.php
Comment on lines +87 to +101
public function __call(string $method, array $args): mixed
{
try {
return $this->statement->{$method}(...$args);
} catch (\Throwable $e) {
if ($this->pdo->inTransaction() || !Connection::hasError($e)) {
throw $e;
}

Console::warning('[Database] ' . $e->getMessage());
Console::warning('[Database] Lost connection detected. Re-preparing statement...');

$this->reprepare();

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

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Gate statement replay to idempotent or transaction-managed operations.

Line 101 retries the same statement after reconnect without knowing whether the first execution reached the server. For non-transactional writes such as updates/increments, a disconnect after server-side apply but before client acknowledgement can double-apply the mutation; the internal reconnect also bypasses adapter execution hooks such as Postgres’ per-query statement_timeout. Move retry orchestration to a layer that can prove idempotency/reapply adapter session state, or make write replay opt-in through transaction-level retry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/PDOStatement.php` around lines 87 - 101, The __call method
unconditionally retries the statement execution after a reconnect without
verifying whether the first execution already completed on the server, which can
cause non-transactional write operations to be applied twice and bypass adapter
execution hooks like statement_timeout. Gate the statement replay at line 101 to
only retry for idempotent operations or operations within an active transaction,
or move the retry orchestration to a higher-level layer that can ensure
idempotency and manage adapter session state. Modify the retry logic after
reprepare() to check the operation type or transaction context before
re-executing the statement method.

Comment thread src/Database/PDOStatement.php Outdated
Comment thread tests/unit/PDOStatementTest.php
@abnegate abnegate force-pushed the fix/adapter-owned-connection-recovery branch from 5a807d7 to 682c863 Compare June 18, 2026 06:43
@abnegate

Copy link
Copy Markdown
Member Author

Thanks for the thorough pass — addressed the P1s in 682c8638:

  • Preserve statement iteration (PDOStatement not iterable): fixed. The wrapper now implements \IteratorAggregate with getIterator() delegating to the underlying statement, so foreach ($pdo->prepare(...) as $row) and getDriver() consumers iterate rows again. Regression test added.
  • Limit retry methods: fixed. __call now only reconnects + replays for execute(). Any other method (fetch/fetchAll/rowCount/closeCursor) rethrows the connection error instead of re-running against an unexecuted statement. Test added.
  • Recover prepare failures: fixed. Restored reconnect-and-retry in prepareNative() for a lost connection outside a transaction, so native-prepares (where prepare() contacts the server) recover, matching __call(). No-op under emulated prepares. Test added.
  • Keep bound references: fixed. bindParam() now stores the variable by reference, so a value changed between bind and execute() is what gets replayed after a reconnect.
  • Preserve omitted defaults (bindColumn): fixed. We now track how many optional args were actually supplied (func_num_args) and forward/replay only those, so omitted ones keep PDO's real defaults instead of explicit nulls.
  • Preserve prepare options: already addressed at HEAD — prepare() threads $options into the wrapper and reprepare() passes them back into prepareNative(). (The comment was against an earlier commit.)

SQLite transactions reconnect — I don't think this one is reachable, but want a second opinion before closing it: the reconnect branch requires both !inTransaction() and Connection::hasError($e). hasError only matches DetectsLostConnections' network-DB strings (server has gone away, Lost connection, SSL/TCP/getaddrinfo, …) plus Max connect timeout reached. SQLite is file/in-memory and its errors (database is locked, disk I/O error, database disk image is malformed) match none of those, so hasError is false and the wrapper rethrows — it never reaches the reconnect path during a SQLite BEGIN IMMEDIATE transaction. With the "execute-only" change above it's further constrained. I deliberately avoided giving the wrapper its own transaction counter to track SQLite's raw-BEGIN state, since a second source of truth desyncing from the real one is exactly what caused the original outage. If you can construct a SQLite error that satisfies hasError, I'll add an adapter-driven transaction signal — otherwise I'll resolve this as not-reachable.

Full unit suite (365), PHPStan L7, and Pint are green.

abnegate added a commit to appwrite/appwrite that referenced this pull request Jun 18, 2026
…le PDOProxy

The Swoole PDOProxy keeps its own transaction counter that desyncs on a
mid-transaction connection loss and is never reset on reconnect or pool checkin,
poisoning pooled connections (every later startTransaction trusts the stale
counter and fails with "There is no active transaction" until restart).

utopia-php/database#896 moves connection-loss recovery into the adapter
(reconnect-on-execute via Utopia\Database\PDOStatement + withTransaction replay)
with the real PDO as the single source of truth, so the proxy is no longer
needed and the desync is structurally impossible.

- registers.php: wrap connections in Utopia\Database\PDO directly instead of
  Swoole\Database\PDOProxy.
- Set PDO::ATTR_ERRMODE => ERRMODE_EXCEPTION explicitly (previously enforced by
  the proxy; also defaulted by #896, set here for clarity).
- Require the #896 branch via a VCS repository until it is tagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/Database/PDOStatement.php Outdated
@abnegate abnegate force-pushed the fix/adapter-owned-connection-recovery branch from 682c863 to 9bab43f Compare June 18, 2026 06:49
@abnegate

Copy link
Copy Markdown
Member Author

On the remaining CodeRabbit items:

Match prepareNative() expectation (test) — fixed in 9bab43f0; the mock now asserts with('SELECT :id', []).

Gate statement replay to idempotent / transaction-managed operations — I've scoped this rather than coded it, and want to lay out why I think that's right for this PR:

  • The wrapper now only retries execute() after a reconnect (the earlier "retries every method" issue is fixed), and only when !inTransaction().
  • All DML in the adapters runs inside Database::withTransaction() (14 call sites cover create/update/upsert/increment/decrement/delete). Inside a transaction the wrapper rethrows — it does not retry — so the transaction-level retry owns recovery there. That means the statement-level retry path is reached only by reads (idempotent) and DDL (which loud-fails on replay, e.g. "table already exists" — not a silent double-apply). There is no non-transactional DML that this path would silently re-apply.
  • At-least-once-on-ack-loss (a disconnect after server apply but before the client ack) and loss of per-connection session state (statement_timeout, etc.) on reconnect are inherent to any retry/reconnect-based recovery. They already exist in the pre-existing withTransaction() retry loop and in PDO::__call(), and they existed in the Swoole\Database\PDOStatementProxy this replaces — which actually retried every method, so this change is strictly safer, not riskier.

So this PR doesn't introduce new exposure; it reduces it. True exactly-once writes (idempotency keys / dedup) and replaying session state across a reconnect are worthwhile hardening but are an architectural change well beyond removing the Swoole proxy. Happy to open a separate tracking issue for both rather than fold them in here.

The other two (prepare-time reconnect for native prepares, and by-reference bindParam/bindColumn) are already marked addressed in 682c863.

abnegate added a commit to appwrite/appwrite that referenced this pull request Jun 18, 2026
…le PDOProxy

The Swoole PDOProxy keeps its own transaction counter that desyncs on a
mid-transaction connection loss and is never reset on reconnect or pool checkin,
poisoning pooled connections (every later startTransaction trusts the stale
counter and fails with "There is no active transaction" until restart).

utopia-php/database#896 moves connection-loss recovery into the adapter
(reconnect-on-execute via Utopia\Database\PDOStatement + withTransaction replay)
with the real PDO as the single source of truth, so the proxy is no longer
needed and the desync is structurally impossible.

- registers.php: wrap connections in Utopia\Database\PDO directly instead of
  Swoole\Database\PDOProxy.
- Set PDO::ATTR_ERRMODE => ERRMODE_EXCEPTION explicitly (previously enforced by
  the proxy; also defaulted by #896, set here for clarity).
- Require the #896 branch via a VCS repository until it is tagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…woole PDOProxy dependency

The Swoole PDOProxy keeps its own transaction counter that is incremented on
beginTransaction but only decremented on a *successful* commit/rollback, and is
never reset on reconnect or on pool checkin (utopia-php/pools does not call its
reset() hook). A connection lost mid-transaction therefore leaks the counter and
poisons the pooled connection: every later startTransaction trusts the stale
counter, rolls back a transaction the real connection no longer holds, and fails
with "There is no active transaction". This produced a sustained write outage
across all projects on cloud nyc3.

Make the library self-sufficient for connection-loss recovery so consumers no
longer need to wrap connections in a Swoole PDOProxy:

- PDOStatement wraps prepared statements and transparently re-prepares on the
  reconnected PDO when the connection is lost at execution time, replaying bound
  params/attributes. Recovery is skipped inside a transaction, where it rethrows
  so withTransaction can replay the whole transaction from the start.
- PDO::prepare() returns the wrapper; prepareNative() re-prepares raw on the
  reconnected connection. ERRMODE_EXCEPTION is enforced by default.
- withTransaction() reconnects on a lost connection before replaying, so the
  retry runs on a fresh, transaction-less connection.
- Transaction state now has a single source of truth (the real PDO via
  Utopia\Database\PDO::inTransaction()); there is no separate counter to desync.
- Replace the Swoole\Database\PDOStatementProxy type hints in the SQL adapters.

Stacked on #895.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abnegate abnegate force-pushed the fix/adapter-owned-connection-recovery branch from 9bab43f to 12c3bf1 Compare June 18, 2026 07:01
@abnegate

Copy link
Copy Markdown
Member Author

Re-review on 682c863 surfaced one new one — Preserve binding order (PDOStatement.php): fixed in 12c3bf1d. bindValue()/bindParam() now record call order in a single bindOrder list, and reprepare() replays value/param bindings in that exact order, so a placeholder rebound across methods replays with the binding the caller applied last (matching PDO). Regression test added (bindValue(':id','old') then bindParam(':id', $new) → replay is value:old, param:new).

For the record, on the SQLite transactions reconnect flag from the first pass (not re-raised since): I believe it's unreachable rather than fixed-in-code, and want to flag my reasoning in case you disagree. The reconnect path needs both !inTransaction() and Connection::hasError($e). hasError only matches DetectsLostConnections' network-DB markers (server has gone away, Lost connection, SSL/TCP/getaddrinfo, Max connect timeout reached, …). SQLite is file/in-memory; its errors (database is locked, disk I/O error, database disk image is malformed) match none, so hasError is false and the wrapper rethrows — it never reconnects during a SQLite BEGIN IMMEDIATE transaction. I deliberately didn't give the wrapper its own transaction counter to track SQLite's raw-BEGIN state, since a second source of truth desyncing from the real one is exactly what caused the incident this PR fixes. If you can produce a SQLite error that satisfies hasError, I'll wire an adapter-driven transaction signal instead.

Full unit suite (366), PHPStan L7, Pint green at 12c3bf1d.

abnegate added a commit to appwrite/appwrite that referenced this pull request Jun 18, 2026
…le PDOProxy

The Swoole PDOProxy keeps its own transaction counter that desyncs on a
mid-transaction connection loss and is never reset on reconnect or pool checkin,
poisoning pooled connections (every later startTransaction trusts the stale
counter and fails with "There is no active transaction" until restart).

utopia-php/database#896 moves connection-loss recovery into the adapter
(reconnect-on-execute via Utopia\Database\PDOStatement + withTransaction replay)
with the real PDO as the single source of truth, so the proxy is no longer
needed and the desync is structurally impossible.

- registers.php: wrap connections in Utopia\Database\PDO directly instead of
  Swoole\Database\PDOProxy.
- Set PDO::ATTR_ERRMODE => ERRMODE_EXCEPTION explicitly (previously enforced by
  the proxy; also defaulted by #896, set here for clarity).
- Require the #896 branch via a VCS repository until it is tagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abnegate abnegate merged commit fff9f0e into main Jun 18, 2026
22 checks passed
@abnegate abnegate deleted the fix/adapter-owned-connection-recovery branch June 18, 2026 10:37
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