Skip to content

Implement GH-8967: Add PDO_SQLITE_ATTR_TRANSACTION_MODE #19317

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

stancl
Copy link

@stancl stancl commented Jul 30, 2025

Resolves #8967

First time contributing to this repo, so please review carefully. Especially the use of zvals and the placement of new constants.

This PR lets the user select which transaction mode Pdo\Sqlite should be using. When a transaction mode is not specified, SQLite defaults to DEFERRED transactions which cause issues with locks in concurrent requests. See https://www.sqlite.org/lang_transaction.html

When you start a deferred transaction, the lock is only acquired when you actually interact with the database. If you have a transaction that first reads and only then tries to write, it will start by acquiring a shared lock and only when you try to write does it try to upgrade to an exclusive lock. However, this logic of upgrading locks doesn't really respect the busy_timeout pragma, so it just immediately fails with SQLITE_BUSY.

On the other hand, when you start an immediate transaction, you don't have to worry about getting SQLITE_BUSY unless another transaction holds a lock for longer than the busy_timeout. So the new transaction just waits for a bit rather than immediately failing. This is the behavior you want in production.

If you look at the linked issue, I include a simple reproduction using pcntl_fork() that uses two concurrent processes. If you run the reproduction just as php test.php, one of the transactions fails with SQLITE_BUSY. If you run it as php test.php immediate, the code does not use $pdo->beginTransaction() and instead uses $pdo->exec('begin immediate transaction'). With that, the second transaction simply waits for the first one to release the lock.

The problem with starting transactions using a custom statement you exec() is that this is simply not an option in many cases, frameworks prefer calling $pdo->beginTransaction(). I've had to solve this in an application of mine by creating a wrapper around the framework's transaction function that first writes to a dummy table and only then executes the callback. That way the transaction always starts with a write lock and doesn't run into the issue of trying to promote a lock. But this doesn't work in every scenario, for instance when the framework's own logic heavily uses transactions that you have no way of customizing with your own wrappers. Not like the wrappers are a proper solution anyway.

PDO attributes are a perfect fit for this because you can simply set the attribute and no other logic has to change. Most frameworks let you set these in a config file as well.

The only thing I'm not sure about is how to structure these attributes. The transaction mode has 3 possible values. We need to represent those somehow. We could take the transaction mode string but then we'd need to deal with managing the lifetime of that string allocation. We could use boolean attributes like ATTR_TRANSACTION_MODE_DEFERRED => true but then we'd need to handle invalid states like two of these attributes being set at once. For that reason I went with one attribute as the "key" and 3 attributes as the "values". These are just class constants on Pdo\Sqlite.

I've included very basic tests (in the pdo_sqlite/tests/subclasses directory which I think is correct since this interacts with Pdo\Sqlite). They test the attribute userland API and then that beginTransaction() actually respects the transaction mode using a simplified reproduction of the lock contention scenario in my original comment. It uses named in-memory DBs (that way separately created PDO instances can use the same memory region) and infers the transaction mode used in $pdo based on the error message $pdo2 gets (or doesn't get) when it tries to start an immediate transaction.

Comment on lines +260 to +263
case 0:
case PDO_SQLITE_ATTR_TRANSACTION_MODE_DEFERRED:
begin_statement = "BEGIN DEFERRED TRANSACTION";
break;
Copy link
Author

@stancl stancl Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 0 case is when no transaction mode has been set, so we use the default (deferred).

Is it a safe assumption that driver_data is always zero-initialized? In the code I could find, I saw that driver_data is allocated with pecalloc().

Comment on lines 311 to 319
case PDO_SQLITE_ATTR_TRANSACTION_MODE:
zend_long mode;
if (H->transaction_mode == 0) {
mode = PDO_SQLITE_ATTR_TRANSACTION_MODE_DEFERRED;
} else {
mode = H->transaction_mode;
}
ZVAL_LONG(return_value, mode);
break;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as my previous review https://github.com/php/php-src/pull/19317/files#r2243162264, this depends on driver_data being zero-initialized.

@stancl stancl changed the title Add PDO_SQLITE_ATTR_TRANSACTION_MODE Implement GH-8967: Add PDO_SQLITE_ATTR_TRANSACTION_MODE Jul 30, 2025
@stancl
Copy link
Author

stancl commented Jul 31, 2025

@kocsismate @SakiTakamachi Will this make it into 8.5 if merged before the feature freeze?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow the programmer to set sqlite transaction mode when using PDO
1 participant