From 4cc4da8b1fbdd5831c977aa9638a1eaf706dc615 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 21 Aug 2026 20:38:20 +0800 Subject: [PATCH] fix(database): stop persistent PDO handles sharing one MySQL transaction 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. --- config/database.connections.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/config/database.connections.php b/config/database.connections.php index e0851fff..581dc3a9 100644 --- a/config/database.connections.php +++ b/config/database.connections.php @@ -20,7 +20,19 @@ } $mysql_options = [ - PDO::ATTR_PERSISTENT => true, + // Persistent connections keep the MySQL session alive in PHP's persistent pool + // after the PDO object is gone, and hand that same session to the next PDO + // built from the same DSN/user/password - including one built while another + // handle is still using it. Two handles then share one transaction: a COMMIT + // through either ends it for both, so the other's commit() raises + // "There is no active transaction" for a write that already committed, and the + // caller reports failure for data that landed. Laravel cannot detect this, + // because commit() gates on its own $transactions counter rather than on + // PDO::inTransaction() (Illuminate\Database\Concerns\ManagesTransactions). + // + // Off by default. Set DB_PERSISTENT=true only where the reconnect cost is + // measured and no request opens a transaction. + PDO::ATTR_PERSISTENT => env('DB_PERSISTENT', false), PDO::ATTR_TIMEOUT => 5, ];