Skip to content

fix(database): stop persistent PDO handles sharing one MySQL transaction - #243

Open
roncodes wants to merge 1 commit into
mainfrom
fix/persistent-pdo-transaction-aliasing
Open

fix(database): stop persistent PDO handles sharing one MySQL transaction#243
roncodes wants to merge 1 commit into
mainfrom
fix/persistent-pdo-transaction-aliasing

Conversation

@roncodes

@roncodes roncodes commented Aug 21, 2026

Copy link
Copy Markdown
Member

The fault

A write lands in MySQL and the API still answers 422:

{"errors":["There is no active transaction"]}

The row is created anyway, so anyone who retries after seeing the error applies the write twice.

Observed across paths with nothing in common — onboarding account creation, ledger invoice creation, inventory stock adjustments — because the fault is in the connection options, not in any caller.

Mechanism

Connection::commit() decides whether to issue a COMMIT from its own $transactions counter. PDO decides whether a COMMIT is legal from the MySQL server's SERVER_STATUS_IN_TRANS flag. Nothing reconciles the two, so the moment anything ends the server-side transaction without going through the Connection, every statement since BEGIN is already durable and the COMMIT throws.

PDO::ATTR_PERSISTENT => true is what makes that reachable. PHP keeps the MySQL session alive in its persistent pool after the PDO object is destroyed and hands that same session to the next PDO built from the same DSN/username/password — including one built while another handle is still using it.

Reproduced directly:

A conn_id=191316  B conn_id=191316   same underlying session: YES
B->beginTransaction() threw: There is already an active transaction
B->commit() OK
committed rows: 1                                    <-- A's write, committed by B
A->commit() threw: There is no active transaction    <-- the 422

The change

PDO::ATTR_PERSISTENT => env('DB_PERSISTENT', false) on the mysql and sandbox connections. DB_PERSISTENT=true restores the old behaviour.

Blast radius

This changes connection handling for every consumer of core-api, on every request and every queued job.

  • Each PDO now opens a real MySQL connection instead of adopting a pooled session. Under Octane that is one connection per worker thread, held for the worker's lifetime — measured at 17 connections on a 24-thread FrankenPHP container, unchanged from before this patch.
  • Deployments that ran short-lived PHP-FPM workers rather than Octane will see genuine per-request connect cost (~1–3 ms) where the pool previously absorbed it. DB_PERSISTENT=true is the escape hatch, but note it re-arms this bug for any request that opens a transaction.
  • Persistent connections also silently defeated Octane's DisconnectFromDatabases listener — disconnect() dropped only the PHP object and left the server-side connection open. Anyone who enabled that listener to control connection counts was not getting it; they will now.

Verification

Two handles built from the application's own live config now get distinct sessions:

ATTR_PERSISTENT : false
handle A session: 191792
handle B session: 191793
ISOLATED - handles cannot end each other's transaction.

Notes

  • composer.json still reads version: 1.6.59. This needs to ship as ≥ 1.6.60 to reach consumers pinned at ^1.6.59 — I have not bumped it or cut a dev-v* release branch, since that triggers the release/tag flow.
  • Companion change in fleetbase/fleetbase reverts DisconnectFromDatabases to the upstream Octane default and adds an opt-in tripwire that logs this divergence at the statement that causes it.

The mysql and sandbox connections were opened with PDO::ATTR_PERSISTENT.
PHP then keeps the MySQL session alive in its persistent pool after the PDO
object is destroyed, and hands that same session to the next PDO built from
the same DSN, username and password - including one built while another
handle is still using it.

Two handles then share one transaction, and a COMMIT through either ends it
for both. The loser's commit() raises "There is no active transaction" for
writes that have already been made durable, so the request reports failure for
data that landed and anyone who retries applies it twice. Reproduced directly:
two live handles reporting the same CONNECTION_ID, one commit, and the other
raising the exact error with the row already visible from a third connection.

Laravel cannot detect this. Connection::commit() decides whether to issue a
COMMIT from its own $transactions counter, while PDO decides whether a COMMIT
is legal from the server's SERVER_STATUS_IN_TRANS flag; nothing reconciles the
two.

Observed in production paths that have nothing to do with each other -
onboarding account creation, ledger invoice creation, and inventory stock
adjustments - because the fault is in the connection options, not in any
caller.

Persistent connections also silently defeat Octane's DisconnectFromDatabases
listener: disconnect() drops the PHP object and leaves the server-side
connection open. Measured on a dev stack, 40 concurrent requests left 17 MySQL
connections open and still idle minutes later, with the listener enabled.

Defaults to off. DB_PERSISTENT=true restores the previous behaviour for
deployments that have measured the reconnect cost and where no request opens a
transaction.
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (6317e91) to head (4cc4da8).

Additional details and impacted files
@@             Coverage Diff             @@
##                main      #243   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
  Complexity      6730      6730           
===========================================
  Files            397       397           
  Lines          22448     22448           
===========================================
  Hits           22448     22448           
Flag Coverage Δ
backend 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

1 participant