Problem/Motivation
This @todo exists in Drupal\KernelTests\Core\Database\TransactionTest::testTransactionWithDdlStatement()
try {
$transaction->rollback();
unset($transaction);
// @TODO: an exception should be triggered here, but is not, because
// "ROLLBACK" fails silently in MySQL if there is no transaction active.
// $this->fail(t('Rolling back a transaction containing DDL should fail.'));
}
This has been fixed in PHP 8.0 due to https://github.com/php/php-src/commit/990bb34891c83d12c5129fd781893704f9...
This doesn't only affect rollback()
it also affects commit()
. Now we have to deal with the fact that PHP 7 and PHP 8 behave in different ways.
Proposed resolution
If a COMMIT fails due to a transaction not being available on PHP 8 then we have to carry on as if it was successful because this is the behaviour under PHP 7. If we don't then things that used to work like the table creation when it doesn't exist in \Drupal\Core\Routing\MatcherDumper::dump() will no longer work.
The trickier decision is for ROLLBACK. On PHP 7 rolling back without a transaction will not fail whilst on PHP 8 it will. Given we only try to rollback when there is a an error doing things like
catch (\Exception $e) {
$transaction->rollBack();
watchdog_exception('Routing', $e);
throw $e;
}
whatever happens we're going to continue to throw an exception. It feels like it is a question of which exception. The current approach is to convert the exception triggered on PHP 8 in $transaction->rollBack();
and replace it with as warning that the transaction has not been rolled back and then we'll continue to report the real exception that has caused the problem.
Remaining tasks
User interface changes
None
API changes
None
Data model changes
None
Release notes snippet
None (there is no behaviour change on PHP 8 - other than a helpful warning when an exception has triggered a rollback that things might be more broken than you'd expect.
Comment | File | Size | Author |
---|---|---|---|
#24 | 2736777-24.patch | 4.76 KB | alexpott |
#24 | 22-24-interdiff.txt | 1.94 KB | alexpott |
Comments
Comment #2
alexpottThis is turns out to be fixed in PHP 8.0 and results in problems for Drupal 8 on PHP 8 with MySQL. There are code paths that only work when committing when a transaction does not exist because MySQL does not report an error. For example: \Drupal\Core\Routing\MatcherDumper::dump() - if the router table does not exist we'll create the table but on MySQL DDL statements will cause the transactions to be committed.
Comment #3
alexpottHere's the patch from #3156595: Make Drupal 9 installable on PHP8
Targeting 9.1.x as that is the release we want to be PHP 8 compatible.
Comment #4
alexpottTrying to update the issue summary - in doing that I have a concern with solution. Given that when rolling back a transaction in core we're doing something like
the current solution of actually throwing the exception on PHP 8 when there is no active transaction means that we'll never hit the lines:
Therefore on PHP 8 rather than being more accurate in the exception we report we are going to lose information. I think the correct thing here is to make PHP 7 and 8 behave the same and eat the exception in PHP 8 so we can report the real reason for the rollback which is the earlier exception. I think we need to issue a warning telling the user that due to MySQL not having transactional DDL the database might be in an inconsistent state because we are unable to rollback the transaction.
Comment #5
alexpottUpdating patch to work in way that is consistent for PHP 7 and PHP 8 but has the added benefit on PHP 8 that you get a warning that something v bad has happened.
Comment #6
Ghost of Drupal PastIs the message
$e->getMessage() === 'There is no active transaction'
reliable? It's coming from https://github.com/php/php-src/blob/bd555b6ccaa9f5e24e2b071067ed1ed51fe6... but I do wonder what's the policy on the stability of these messages. Perhaps in light of the recent commit our issue attacks we could ask PHP internals to please change the code from a fixed zero to something and add a PDO constant for it so it is reliable? Until then perhaps move this check into a method because it occurs twice.Comment #7
mondrakeWouldn't it be better to rely on \PDO::inTransaction instead of catching an exception to determine if we are in a transaction currently, and throw
DatabaseExceptionWrapper
if not (or,TransactionNoActiveException
)? Then yes, we can still catch the failure and rethrow, as last resort.Comment #8
andypost@mondrake looking at php-src it sounds much cleaner - cost of one function call but pdo already doing it later https://github.com/php/php-src/blob/bd555b6ccaa9f5e24e2b071067ed1ed51fe6...
Comment #9
alexpottWe cannot re-throw the exception. That will break Drupal. We rely on the PHP 7 of not failing when calling commit. And if we want decent errors when rolling back we need to ignore it too.
If we call
Pdo::inTransaction()
then we'll take an extra trip to the server - see https://github.com/php/php-src/blob/master/ext/pdo_mysql/mysql_driver.c#... - I guess this is not so bad given this will not be the normal code path. It only happens when there are DDL statements during a transaction which only occurs when creating things like cache tables or the router.Comment #10
alexpottI just tried to implement this. It's not so pretty because we need to copy code from
\Drupal\Core\Database\Connection::rollBack
to\Drupal\Core\Database\Driver\mysql\Connection::rollBack()
but I think it's okay.Comment #11
alexpottSadly PHP 7 actually returns TRUE for $this->connection->inTransaction() after a DDL statement has caused an automatic commit. :( I was hoping that we'd get the warning on PHP 7 too...
Comment #12
Ghost of Drupal PastIf I understand correctly, the above won't work? Then another thought: the DDL statements are ours, the transaction code is ours. What if we explicitly set a flag "DDL was ran in this transaction"? We could add a
query
, aqueryRange
and anupdate
method toDrupal\Core\Database\Driver\mysql\Schema
and these would flag the transaction before calling the relevant method on the connection.Comment #13
alexpott@Charlie ChX Negyesi what do you mean by won't work precisely? #10 works fine on PHP 7 and PHP 8 - see #3156595-128: Make Drupal 9 installable on PHP8. What #11 is pointing out is that the new code unfortunately does not improve the error reporting in PHP 7. Yes we could add more state to the MySQL driver but I think the solution in #10 is okay and probably preferable. If you call rollback on a transaction in MySQL where a DDL statement has occurred now on PHP 8 you get a warning that things are properly broken - which is an improvement from HEAD/PHP 7. And there's no way we can do the rollback (because MySQL just does not support it).
Comment #14
Ghost of Drupal PastI misunderstood #11 then, I thought you are saying the patch in #10 didn't work out and I was trying to come up with something bettter but if it works, that's just great.
Comment #15
catchWe should add a comment here that inTransaction() will return TRUE on PHP 7 and we'll never reach the trigger_error() there. I think it's fine that it doesn't - it's just the status quo which we won't support in Drupal 10 anyway.
If this is copied, can we point to the method where it's copied from explicitly?
Comment #16
alexpottAddressing #15. Note we will never be able to throw the exception from \Drupal\Core\Database\Driver\mysql\Connection::rollBack() because we want the real reason to be reported.
Comment #17
catchOnly thing I'm wondering here is if we could structure the comment as if we already require PHP 8 (i.e. the future reality), then in a separate paragraph discuss the PHP 7 situation. This way when we do drop support for PHP 7 we should be able to just delete lines rather than restructure. It might also make it easier to follow.
Comment #18
alexpottGood point. I think @catch is correct that this does make it easier to follow.
Comment #19
catchThat's much easier to read, which I didn't really expect, but nice side effect. I think this is ready to go.
Comment #20
alexpottDoh I've fallen into the expect exception trap... on PHP 8 the
$this->assertRowPresent('row');
is never called because we bail out of the test after the warning...The changes attached ensure that the test fails on PHP 7 and PHP 8 when you change
$this->assertRowPresent('row');
to$this->assertRowPresent('BLAH');
Comment #21
alexpottComment #22
alexpottThe fail message is incorrect. It should produce an exception - if it does then everything is broken for us. But on PHP 8 it should now produce a warning.
Comment #23
mondrakePity that this will be executed twice at every rollback on MySql, here and again once calling
parent::rollBack
. Is it absolutely necessary to have it here?Comment #24
alexpott@mondrake yes it is. We need to throw those exceptions on PHP 8... but we can do a bit better because we only need to do this if we're not calling the parent.
Comment #25
mondrakeAssuming it turns out green, this looks neat and ready to me.
Comment #26
alexpott#3156595-130: Make Drupal 9 installable on PHP8 is testing this patch against PHP 8.0 and it is green too.
Comment #29
catchAsked @alexpott about a test-only patch, if we remove the fix here, Drupal isn't installable at all, so the test run blows up - looks like this: https://dispatcher.drupalci.org/job/drupal_patches/65266/
So the test can pass, but it's hard to demonstrate that it will fail in isolation. However thousands of fatal errors is a good clue that something's wrong so that's fine.
Committed/pushed to 9.2.x and cherry-picked to 9.1.x, thanks!