fix(database): stop persistent PDO handles sharing one MySQL transaction - #243
Open
roncodes wants to merge 1 commit into
Open
fix(database): stop persistent PDO handles sharing one MySQL transaction#243roncodes wants to merge 1 commit into
roncodes wants to merge 1 commit into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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$transactionscounter. PDO decides whether a COMMIT is legal from the MySQL server'sSERVER_STATUS_IN_TRANSflag. Nothing reconciles the two, so the moment anything ends the server-side transaction without going through theConnection, every statement since BEGIN is already durable and the COMMIT throws.PDO::ATTR_PERSISTENT => trueis 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:
The change
PDO::ATTR_PERSISTENT => env('DB_PERSISTENT', false)on themysqlandsandboxconnections.DB_PERSISTENT=truerestores the old behaviour.Blast radius
This changes connection handling for every consumer of core-api, on every request and every queued job.
DB_PERSISTENT=trueis the escape hatch, but note it re-arms this bug for any request that opens a transaction.DisconnectFromDatabaseslistener —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:
Notes
composer.jsonstill readsversion: 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 adev-v*release branch, since that triggers the release/tag flow.fleetbase/fleetbaserevertsDisconnectFromDatabasesto the upstream Octane default and adds an opt-in tripwire that logs this divergence at the statement that causes it.