jmurph206's avatar

Antics: Transaction level not reflecting PDO transaction state

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?

0 likes
2 replies
rodrigo.pedra's avatar

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

True. Laravel uses an internal counter to track open transactions:

1 - Increment

$this->transactions++;

https://github.com/laravel/framework/blob/d1a023b8c560510daf40ae175d6432a99bf5ffee/src/Illuminate/Database/Concerns/ManagesTransactions.php#L115

2 - Decrement (gracefully)

$this->transactions = max(0, $this->transactions - 1);

https://github.com/laravel/framework/blob/d1a023b8c560510daf40ae175d6432a99bf5ffee/src/Illuminate/Database/Concerns/ManagesTransactions.php#L50

Ans several other spots on that trait can decrease the transaction count.

So it is not in sync with PDO transaction state. Should be most of the times, but as it tracks independently it can become out of sync with some weird thing happens.

One easy example is when you execute a TRUNCATE inside a transaction. For most drivers (MySQL, SQL Server for sure) this will commit any open transactions in the database side. But as Laravel does not track which SQL statements are executed and can issue an auto-commit operation, it will report an open transaction.

PDO for MySQL, until PHP 8.0, used a similar mechanism. You can read on the migration guide for PHP 8:

PDO::inTransaction() now reports the actual transaction state of the connection, rather than an approximation maintained by PDO. If a query that is subject to "implicit commit" is executed, PDO::inTransaction() will subsequently return false, as a transaction is no longer active.

https://www.php.net/manual/en/migration80.incompatible.php#migration80.incompatible.pdo-mysql

So, as you are using Laravel 5.8, PHP 7.3, and MySQL you could end up with different transaction states between Laravel, PDO (PHP) and MySQL. Very unlikely to happen, but possible due to both Laravel and PHP (before 8.0) each using their own custom mechanisms for tracking open transactions.

Not sure if this is something easy to solve on the Laravel side.

Hope this helps.

1 like
jmurph206's avatar

Thanks for the info. Fortunately we've done well to isolate the implicit commit type queries to our migrations and out of the app code. The app is distributed across sites which leads to occasional network related blackouts. If this happens prior to calling commit, it could produce a missed error condition.

I think I may extend the laravel MySQL connection and try to implement PDO transaction validation. Additionally, I might be able to do something like this to attempt to catch the warning on commit and throw as an exception.

1 like

Please or to participate in this conversation.