I've been doing some research into this today and started thinking this was a bug I had to submit in the laravel/framework github. But the more, I read, now I think that part works intentionally, and there's other underlying issues. I'm hoping you kind folks here might have some ideas on how we can go about stabilizing transactions.
Laravel: 5.8 (planned upgrade in February)
PHP version: 7.3
Database: MySQL 8
I'd had the suspicion for a while that there's potential for the transaction state reported by Laravel connections to diverge from the state reported by PDO. I believe I might have confirmed it by adding a listener for the Looping queue event to check things out and clean up if anything looks screwy. This is what the relevant part of the handle() method looks like:
/** @var Connection $connection */
foreach (app('db')->getConnections() as $connectionName => $connection) {
$context['database-connection'] = $connectionName;
if ($connection->transactionLevel() > 0) {
Log::channel('database')
->warning(
sprintf('A connection has a transaction level of %d. Forcing rollback.', $connection->transactionLevel()),
$context
);
$connection->rollBack(0);
}
if ($connection->getPdo()->inTransaction()) {
Log::channel('database')
->warning(
'PDO still has an open transaction. Forcing rollback. The result of the previous job may be unsaved.',
$context
);
$connection->getPdo()->rollBack();
}
}
After I implemented this, I started seeing the second error in my logs, but not the first. This tells me, that the transaction state between the Laravel Connection class and the PDO class has diverged and no longer agrees.
For those who are curious, our usage of transactions is pretty vanilla. They always look like this:
DB::connection('my-awesome-connection')->transaction(function () {
// Some wild business logic
});
And we don't touch PDO directly. So I'm not sure why these states could diverge. /shrug
The part where I was thinking it might be a Laravel bug was when I went digging under the hood. In ManagesTransactions.php, you'll find the commit method:
/**
* Commit the active database transaction.
*
* @return void
*
* @throws \Throwable
*/
public function commit()
{
if ($this->transactions == 1) {
$this->getPdo()->commit();
optional($this->transactionsManager)->commit($this->getName());
}
$this->transactions = max(0, $this->transactions - 1);
$this->fireConnectionEvent('committed');
}
Checking the PHP documentation on the PDO::commit() method, you'll see that the method can return a bool, which the Laravel code doesn't seem to care about. I believe the PDO option PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, set in the Connector class is meant to handle that though and require PDO to throw an exception if something were to occur.
Seeing that configuration led me to do further digging on the interwebs, and led to me this PHP bug. With this, knowledge, I set APP_DEBUG to false and set up a test route.
Route::get('test', function () {
$pdo = \Illuminate\Support\Facades\DB::getPdo();
$pdo->beginTransaction();
var_dump($pdo->commit()); // Theres a breakpoint on this line
});
I fired an HTTP request, and when that breakpoint was reached, I shutdown my local mysql, clicked play, and BAM! "/vagrant/routes/web.php:353:boolean true" in the browser. No error. No return of false. Just a "we're good!".
Now for how to fix it... [insert "Sure, Not" from Idiocracy]
Maybe adding an even listener for the TransactionCommitted with something like:
public function handle(TransactionCommitted $committed)
{
if ($committed->connection->transactionLevel() === 0 && $committed->connection->getPdo()->inTransaction()) {
throw new Exception('You\'ve been lied to');
}
}
But obviously, doing this, you lose scope of the cause since we miss the warning. In the end, this is brought on by mysqlnd or PDO not handling these situation correctly and throwing an exception. I haven't found any documentation that suggests this is corrected in later versions of PHP. I suppose I can submit an enhancement request in the framework github to look out for this...
Any thoughts out there of a graceful way to handle this?