diff --git a/packages/mysql-on-sqlite/README.md b/packages/mysql-on-sqlite/README.md index 2c08bec56..2ffa3cff1 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_busy_timeout` | SQLite busy timeout in milliseconds | `10000` | | `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..f9027d73f 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,11 +795,13 @@ 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_busy_timeout Optional. SQLite busy timeout in milliseconds. + * Default 10000. * @type string|null $sqlite_journal_mode Optional. SQLite journal mode. Default 'WAL'. * @type string|int|null $sqlite_synchronous Optional. SQLite synchronous setting. * } * - * @throws InvalidArgumentException When the MySQL version is invalid. + * @throws InvalidArgumentException When the MySQL version or an SQLite option is invalid. * @throws WP_MySQL_On_SQLite_Exception When the driver initialization fails. */ public function __construct( @@ -876,6 +878,7 @@ function ( $key ) { } $connection_options = array( + 'busy_timeout' => $options['sqlite_busy_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/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 78461f1b1..59724f7d6 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -17,9 +17,9 @@ */ class WP_SQLite_Connection { /** - * The default timeout in seconds for SQLite to wait for a writable lock. + * The default time in milliseconds for SQLite to wait for a lock. */ - const DEFAULT_SQLITE_TIMEOUT = 10; + const DEFAULT_SQLITE_BUSY_TIMEOUT = 10000; /** * The supported SQLite journal modes. @@ -76,8 +76,8 @@ class WP_SQLite_Connection { * Must be set when PDO instance is not provided. * @type PDO|null $pdo Optional. PDO instance with SQLite connection. * If not provided, a new PDO instance will be created. - * @type int|null $timeout Optional. SQLite timeout in seconds. - * The time to wait for a writable lock. + * @type int|null $busy_timeout Optional. SQLite busy timeout in milliseconds. + * The time to wait for a lock. Defaults to 10000. * @type string|null $journal_mode Optional. SQLite journal mode. Defaults to WAL. * @type string|int|null $synchronous Optional. SQLite synchronous setting. Defaults to * NORMAL when the effective journal mode is WAL. @@ -107,13 +107,14 @@ public function __construct( array $options ) { // Throw exceptions on error. $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); - // Configure SQLite timeout. - if ( isset( $options['timeout'] ) && is_int( $options['timeout'] ) ) { - $timeout = $options['timeout']; - } else { - $timeout = self::DEFAULT_SQLITE_TIMEOUT; + // Configure SQLite busy timeout. SQLite stores it as a 32-bit signed integer. + $busy_timeout = $options['busy_timeout'] ?? self::DEFAULT_SQLITE_BUSY_TIMEOUT; + if ( ! is_int( $busy_timeout ) || $busy_timeout < 0 || $busy_timeout > 2147483647 ) { + throw new InvalidArgumentException( + 'Invalid SQLite busy timeout. It must be an integer from 0 to 2147483647.' + ); } - $this->pdo->setAttribute( PDO::ATTR_TIMEOUT, $timeout ); + $this->query( 'PRAGMA busy_timeout = ' . $busy_timeout ); // Configure SQLite journal mode. Default to WAL for best throughput. $effective_journal_mode = null; 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..1b8d9137c 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 @@ -133,12 +133,11 @@ public function testSetQuerySucceedsWhileAnotherConnectionHoldsWriteLock(): void // Connection B with zero timeout — any lock conflict fails immediately. $conn_b = new WP_SQLite_Connection( array( - 'path' => $this->db_path, - 'timeout' => 0, + 'path' => $this->db_path, + 'busy_timeout' => 0, ) ); $driver_b = $this->create_driver( $conn_b ); - $conn_b->get_pdo()->setAttribute( PDO::ATTR_TIMEOUT, 0 ); // SET writes nothing, so it must not contend for the write lock. $result = $driver_b->query( "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'" ); @@ -149,6 +148,20 @@ public function testSetQuerySucceedsWhileAnotherConnectionHoldsWriteLock(): void } } + public function testWriteQueryFailsImmediatelyWithZeroBusyTimeoutWhileAnotherConnectionHoldsWriteLock(): void { + $elapsed = $this->measure_write_failure_under_write_lock( 0 ); + + // Far below the 10 second default. + $this->assertLessThan( 1, $elapsed ); + } + + public function testWriteQueryWaitsForBusyTimeoutWhileAnotherConnectionHoldsWriteLock(): void { + $elapsed = $this->measure_write_failure_under_write_lock( 200 ); + + $this->assertGreaterThanOrEqual( 0.2, $elapsed ); + $this->assertLessThan( 5, $elapsed ); + } + private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): void { // Connection A: set up the database. $conn_a = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); @@ -163,12 +176,11 @@ private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): voi // Connection B with zero timeout — any lock conflict fails immediately. $conn_b = new WP_SQLite_Connection( array( - 'path' => $this->db_path, - 'timeout' => 0, + 'path' => $this->db_path, + 'busy_timeout' => 0, ) ); $driver_b = $this->create_driver( $conn_b ); - $conn_b->get_pdo()->setAttribute( PDO::ATTR_TIMEOUT, 0 ); $result = $driver_b->query( $query ); @@ -178,6 +190,43 @@ private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): voi } } + /** + * Run a write query while another connection holds the write lock. + * + * @param int $busy_timeout Busy timeout of the writing connection in milliseconds. + * @return float Time in seconds until the write query failed. + */ + private function measure_write_failure_under_write_lock( int $busy_timeout ): float { + // 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 { + $driver_b = new WP_MySQL_On_SQLite( + sprintf( 'mysql-on-sqlite:path=%s;dbname=wp', $this->db_path ), + null, + null, + array( 'sqlite_busy_timeout' => $busy_timeout ) + ); + + $start = microtime( true ); + try { + $driver_b->query( "INSERT INTO t VALUES (2, 'Bob')" ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'database is locked', $e->getMessage() ); + return microtime( true ) - $start; + } + $this->fail( 'The write query did not fail while another connection held the write lock.' ); + } 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:' ); @@ -192,6 +241,7 @@ private function create_driver( WP_SQLite_Connection $connection ): WP_MySQL_On_ null, array( 'sqlite_pdo' => $connection->get_pdo(), + 'sqlite_busy_timeout' => (int) $connection->query( 'PRAGMA busy_timeout' )->fetchColumn(), 'sqlite_journal_mode' => $connection->query( 'PRAGMA journal_mode' )->fetchColumn(), ) ); 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..bfc0cefd5 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,43 @@ public function test_journal_mode_and_synchronous_driver_options(): void { } } + public function test_busy_timeout_driver_option(): void { + $driver = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:path=:memory:;dbname=wp', + null, + null, + array( + 'sqlite_busy_timeout' => 2500, + ) + ); + + $this->assertSame( + 2500, + (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() + ); + } + + public function test_busy_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_BUSY_TIMEOUT, + (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() + ); + } + + public function test_rejects_invalid_busy_timeout(): void { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid SQLite busy timeout. It must be an integer from 0 to 2147483647.' ); + + new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:path=:memory:;dbname=wp', + null, + null, + array( 'sqlite_busy_timeout' => '10000' ) + ); + } + 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/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php index b65147a47..b6f98562d 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php @@ -189,6 +189,59 @@ public function testDriverKeepsConfiguredJournalMode(): void { $this->assertSame( 'delete', $this->get_journal_mode( $driver->get_connection() ) ); } + public function testDefaultBusyTimeout(): void { + $connection = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); + + $this->assertSame( '10000', $this->get_busy_timeout( $connection ) ); + } + + public function testBusyTimeoutAcceptsZero(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'busy_timeout' => 0, + ) + ); + + $this->assertSame( '0', $this->get_busy_timeout( $connection ) ); + } + + public function testBusyTimeoutAcceptsMaximumValue(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'busy_timeout' => 2147483647, + ) + ); + + $this->assertSame( '2147483647', $this->get_busy_timeout( $connection ) ); + } + + /** + * @dataProvider provideInvalidBusyTimeouts + */ + public function testInvalidBusyTimeoutThrows( $busy_timeout ): void { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid SQLite busy timeout. It must be an integer from 0 to 2147483647.' ); + new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'busy_timeout' => $busy_timeout, + ) + ); + } + + public function provideInvalidBusyTimeouts(): array { + return array( + 'string' => array( '10000' ), + 'float' => array( 10000.0 ), + 'boolean' => array( false ), + 'array' => array( array( 10000 ) ), + 'negative' => array( -1 ), + 'above maximum' => array( 2147483648 ), + ); + } + /** * Create the database file first, and then make its directory read-only, * so that the WAL sidecar files ("-wal", "-shm") cannot be created. @@ -216,4 +269,8 @@ private function get_journal_mode( WP_SQLite_Connection $connection ): string { private function get_synchronous( WP_SQLite_Connection $connection ): string { return (string) $connection->query( 'PRAGMA synchronous' )->fetchColumn(); } + + private function get_busy_timeout( WP_SQLite_Connection $connection ): string { + return (string) $connection->query( 'PRAGMA busy_timeout' )->fetchColumn(); + } } 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..15d5dc954 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_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? 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..59a9c9acd 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_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? SQLITE_BUSY_TIMEOUT : null, ) ); $translator->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO