diff --git a/packages/mysql-on-sqlite/README.md b/packages/mysql-on-sqlite/README.md index 2c08bec56..2f36fd642 100644 --- a/packages/mysql-on-sqlite/README.md +++ b/packages/mysql-on-sqlite/README.md @@ -76,6 +76,7 @@ version and configuring the SQLite connection: | --- | --- | --- | | `mysql_version` | MySQL version to emulate, represented as an integer | `80038` | | `sqlite_pdo` | Existing PDO SQLite connection | A new connection for `path` | +| `sqlite_timeout` | SQLite busy timeout in whole seconds | `10` | | `sqlite_journal_mode` | SQLite journal mode | `WAL` | | `sqlite_synchronous` | SQLite synchronous setting | `NORMAL` in WAL mode; otherwise the SQLite default | diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 57d72f70c..701e0360a 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -795,6 +795,8 @@ class WP_MySQL_On_SQLite extends PDO { * * @type int $mysql_version Optional. MySQL version to emulate. Default 80038. * @type PDO|null $sqlite_pdo Optional. Existing PDO SQLite connection. + * @type int|null $sqlite_timeout Optional. SQLite busy timeout in whole seconds. + * Default null (10 seconds). * @type string|null $sqlite_journal_mode Optional. SQLite journal mode. Default 'WAL'. * @type string|int|null $sqlite_synchronous Optional. SQLite synchronous setting. * } @@ -876,6 +878,7 @@ function ( $key ) { } $connection_options = array( + 'timeout' => $options['sqlite_timeout'] ?? null, 'journal_mode' => $options['sqlite_journal_mode'] ?? null, 'synchronous' => $options['sqlite_synchronous'] ?? null, 'pdo_options' => $connection_pdo_options, diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php index b693fa1b5..c3f9f3e82 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php @@ -178,6 +178,35 @@ private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): voi } } + public function testWriteQueryFailsImmediatelyWithZeroBusyTimeoutWhileAnotherConnectionHoldsWriteLock(): void { + // Connection A: set up the database and hold a write transaction. + $conn_a = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); + $driver_a = $this->create_driver( $conn_a ); + $driver_a->query( 'CREATE TABLE t (id INT, name VARCHAR(255))' ); + $driver_a->query( "INSERT INTO t VALUES (1, 'Alice')" ); + + // Simulate another PHP process holding a write transaction. + $conn_a->get_pdo()->exec( 'BEGIN IMMEDIATE' ); + + try { + // Connection B with a zero busy timeout — any lock conflict fails + // immediately instead of waiting for the default timeout. + $driver_b = new WP_MySQL_On_SQLite( + sprintf( 'mysql-on-sqlite:path=%s;dbname=wp', $this->db_path ), + null, + null, + array( 'sqlite_timeout' => 0 ) + ); + + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'database is locked' ); + + $driver_b->query( "INSERT INTO t VALUES (2, 'Bob')" ); + } finally { + $conn_a->get_pdo()->exec( 'ROLLBACK' ); + } + } + private function create_in_memory_driver(): WP_MySQL_On_SQLite { $pdo_class = PHP_VERSION_ID >= 80400 ? Pdo\Sqlite::class : PDO::class; $pdo = new $pdo_class( 'sqlite::memory:' ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index ceef0137c..bf7c8d7c3 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -379,6 +379,34 @@ public function test_journal_mode_and_synchronous_driver_options(): void { } } + public function test_timeout_driver_option_sets_sqlite_busy_timeout(): void { + $driver = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:path=:memory:;dbname=wp', + null, + null, + array( + 'sqlite_timeout' => 5, + ) + ); + + // The PDO SQLite driver does not support reading "PDO::ATTR_TIMEOUT" + // back with "getAttribute()", but the SQLite busy timeout reflects it + // in milliseconds. + $this->assertSame( + 5000, + (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() + ); + } + + public function test_timeout_defaults_to_the_connection_default(): void { + $driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp' ); + + $this->assertSame( + WP_SQLite_Connection::DEFAULT_SQLITE_TIMEOUT * 1000, + (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() + ); + } + public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( WP_MySQL_On_SQLite_Statement::class, $result ); diff --git a/packages/plugin-sqlite-database-integration/README.md b/packages/plugin-sqlite-database-integration/README.md index 756ed167b..a875b6a8d 100644 --- a/packages/plugin-sqlite-database-integration/README.md +++ b/packages/plugin-sqlite-database-integration/README.md @@ -58,6 +58,22 @@ for an in-memory SQLite database. The database directory must be writable by PHP Integrations should always read `DB_PATH` after WordPress loads instead of assuming a fixed database path. +## Busy timeout + +SQLite allows only one writer at a time. When a write cannot start because another +connection holds the write lock, SQLite waits up to the busy timeout before failing +with a "database is locked" error. The default timeout is 10 seconds. + +To change the timeout, define the `SQLITE_BUSY_TIMEOUT` constant in `wp-config.php` +with a value in whole seconds: + +```php +// Wait up to 30 seconds for the write lock before failing. +define( 'SQLITE_BUSY_TIMEOUT', 30 ); +``` + +Set it to `0` to fail immediately instead of waiting. + ## Development Run these commands from the repository root: diff --git a/packages/plugin-sqlite-database-integration/readme.txt b/packages/plugin-sqlite-database-integration/readme.txt index ac220b9c4..9c8c5904a 100644 --- a/packages/plugin-sqlite-database-integration/readme.txt +++ b/packages/plugin-sqlite-database-integration/readme.txt @@ -73,6 +73,17 @@ To properly **secure the SQLite database**, define the `DB_PATH` constant to an The value of `DB_PATH` must be an absolute path to the SQLite database file, or `:memory:` for an in-memory SQLite database. The database directory must be writable by PHP. Changing `DB_PATH` selects a different database without moving an existing one. += How can I configure the SQLite busy timeout? = + +SQLite allows only one writer at a time. When a write query cannot start because another connection holds the write lock, SQLite waits up to the busy timeout before failing with a "database is locked" error. The default timeout is 10 seconds. + +To change the timeout, define the `SQLITE_BUSY_TIMEOUT` constant in `wp-config.php` with a value in whole seconds: + + // Wait up to 30 seconds for the write lock before failing. + define( 'SQLITE_BUSY_TIMEOUT', 30 ); + +Set it to `0` to fail immediately instead of waiting. + = What does the plugin require? = In addition to the WordPress and PHP versions listed above, the plugin requires the PDO SQLite PHP extension and SQLite 3.37.0 or newer. The setup screen also needs write access to the `wp-content` directory and will detect conflicting database drop-ins. diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php index adcbb5c6f..5317d10f8 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php @@ -453,6 +453,7 @@ public function db_connect( $allow_bail = true ) { try { $options = array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, + 'sqlite_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, ); $dbh = new WP_MySQL_On_SQLite( sprintf( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index 7e6610fad..de95f786b 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -30,6 +30,7 @@ function sqlite_make_db_sqlite() { null, array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, + 'sqlite_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, ) ); $translator->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO