Skip to content

Don't take a write lock for SET statements (fixes "database is locked" on connect)#443

Open
gcsecsey wants to merge 1 commit into
WordPress:trunkfrom
gcsecsey:stu-1821-set-no-write-lock
Open

Don't take a write lock for SET statements (fixes "database is locked" on connect)#443
gcsecsey wants to merge 1 commit into
WordPress:trunkfrom
gcsecsey:stu-1821-set-no-write-lock

Conversation

@gcsecsey

@gcsecsey gcsecsey commented Jun 29, 2026

Copy link
Copy Markdown

Related to STU-1821

What changed

Every WordPress request runs SET SESSION sql_mode = … at bootstrap (via wpdb::set_sql_mode()). This statement changes only session state and writes nothing to the database, but the driver acquires an SQLite write lock for it. Under concurrent access, this makes an otherwise read-only request fatal with database is locked (SQLITE_BUSY) the moment any other connection holds the write lock.

This PR fixes the two places the SET path takes a write lock (WP_PDO_MySQL_On_SQLite):

  1. The wrapper transaction. query() opens BEGIN IMMEDIATE (a RESERVED/write lock) for every non-SELECT statement, and SET was treated as a write. → SET is now detected as read-only, so it opens a deferred BEGIN (SHARED) instead — the same treatment SHOW/DESCRIBE already receive (Improve concurrent database access #361).

  2. The empty-result builder. create_result_statement_from_data() fabricates a 0-column result with a no-op INSERT … WHERE FALSE against a regular table. SQLite acquires a write lock for any DML, even one that inserts zero rows, so this re-took a write lock right after the wrapper committed. → The 0-column statement is now built with a no-op INSERT into a connection-private TEMP table, whose temp database has no shared lock.

Both changes are required: with only (1), the fatal simply moves from the BEGIN IMMEDIATE to the no-op INSERT.

Why

This is a direct follow-up to #361 (which stopped SELECT/SHOW/DESCRIBE from taking write locks and closed #318). SET was left on the write path. Because set_sql_mode() runs on every connection, the impact is broad: a single slow request holding a write transaction (a background WooCommerce/Jetpack write, an import, a long render on a heavy site) makes every concurrent request fail at connect, not only concurrent writers. SQLite is single-writer by design, but statements that perform no writes should never contend for the write lock.

Journal mode does not address this on its own: it reproduces identically under both DELETE and WAL (WAL still serialises writers).

Reproduction

Deterministic, single process, two driver connections to one file:

$path = tempnam( sys_get_temp_dir(), 'sqlite' );
@unlink( $path );

$make = fn() => new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => $path ) ), 'db' );

// Build both connections first, so B's schema setup doesn't run under the lock.
$a = $make();
$a->query( 'CREATE TABLE t ( id INTEGER PRIMARY KEY )' );
$b = $make();
$b->execute_sqlite_query( 'PRAGMA busy_timeout = 1500' ); // fail fast on a lock

// A holds a write lock.
$a->query( 'START TRANSACTION' );
$a->query( 'INSERT INTO t ( id ) VALUES ( 1 )' );

// B runs the connect-time statement. It writes nothing, so it must not block.
$b->query( "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'" );
// Before this PR: throws "database is locked". After: returns immediately.

Before: WP_SQLite_Driver_Exception: … database is locked, first in begin_wrapper_transaction(), or after fixing only (1), in create_result_statement_from_data().

After: the SET returns immediately while A still holds its write lock.

This also reproduces end-to-end in a multi-worker WordPress install (eg. PHP-FPM with more than one workers) under concurrent requests, where one slow request holding a write transaction makes concurrent page loads fatal at set_sql_mode.

Tests

Two regression tests added to WP_SQLite_Driver_Concurrency_Tests:

  • testSetQueryOpensReadOnlyTransaction: SET opens BEGIN, not BEGIN IMMEDIATE.
  • testSetQuerySucceedsWhileAnotherConnectionHoldsWriteLock: with another connection holding BEGIN IMMEDIATE and timeout = 0, SET completes instead of throwing SQLITE_BUSY.

Both fail on trunk and pass with this change.

Notes / risk

  • The 0-column contract is preserved. WP_SQLite_Driver::query() returns fetchAll() when columnCount() > 0, otherwise rowCount(). The replacement empty-result statement keeps columnCount() === 0, so writes and SET still return their affected-row count (not an empty array). Verified: running a representative CREATE/INSERT/SELECT/COUNT/UPDATE/DELETE/SET sequence produces byte-identical results before and after.
  • Bonus: real writes (INSERT/UPDATE/DELETE) also fabricate their empty result through this path, which previously meant a second, redundant write-lock acquisition after the real write. That redundant lock is now gone, slightly reducing lock pressure for all writes.
  • Temp-table lifetime. The TEMP table is created once per connection (guarded by a flag); TEMP tables live for the connection's lifetime. A reviewer preferring maximum robustness over the micro-optimisation could drop the flag and always issue the idempotent CREATE TEMP TABLE IF NOT EXISTS.

@gcsecsey gcsecsey marked this pull request as ready for review June 29, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Address database is locked error

1 participant