Preserve existing SQLite journal modes on startup (#54389)

## Why

Opening an initialized database in `DELETE` journal mode previously attempted to convert it to `WAL`, which could fail while another connection held a write lock.

## What changed

- Initialize `WAL` and incremental auto-vacuum only for databases without a schema, including files left by interrupted initialization.
- Preserve journal and vacuum modes for initialized databases, use `synchronous = EXTRA` for `DELETE` connections, and retain `NORMAL` for `WAL`.
- Close failed initialization pools before retrying and restrict background page reclamation to `WAL` databases.

## Testing

Extend SQLite tests to cover interrupted initialization, opening `DELETE` databases under a write lock across all pool connections, persistence after reopening, and lock release after failed initialization.

GitOrigin-RevId: 23acecfe472618dde8545815e9a1058a51591235
This commit is contained in:
Dmitriy Kovalenko
2026-10-15 19:15:04 +00:00
committed by copyberry
parent f847a4bd3c
commit 1ef30bcbe7
3 changed files with 216 additions and 87 deletions
+90 -54
View File
@@ -1,6 +1,6 @@
//! Shared SQLite connection configuration.
//! Writable pools preserve existing vacuum modes and report initialization errors to callers.
//! External lock contention while opening gets a bounded retry before the original error surfaces.
//! Writable pools preserve the journal and vacuum modes of initialized databases.
//! Startup retries SQLITE_BUSY and reports other errors to callers.
#![expect(
clippy::disallowed_methods,
@@ -364,57 +364,9 @@ impl SqliteConfig {
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(DEFAULT_BUSY_TIMEOUT)
.log_statements(LevelFilter::Off);
let connect = || async {
// SQLx retries after_connect errors, eventually replacing them with PoolTimedOut.
// Return the first initialization error directly while opening this pool.
let (init_error_tx, mut init_error_rx) = tokio::sync::mpsc::channel(/*buffer*/ 1);
let pool_options = SqlitePoolOptions::new().max_connections(5).after_connect(
move |connection, _metadata| {
let init_error_tx = init_error_tx.clone();
Box::pin(async move {
let result = async {
let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
.fetch_one(&mut *connection)
.await?;
// The setter takes the writer lock even when the mode is unchanged.
// Initialize before WAL creates the first database page; preserve
// existing modes, including FULL, without taking the writer lock.
let empty = if mode == 0 {
sqlx::query_scalar::<_, bool>(
"SELECT NOT EXISTS (SELECT 1 FROM sqlite_schema)",
)
.fetch_one(&mut *connection)
.await?
} else {
false
};
if empty {
sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
.execute(&mut *connection)
.await?;
}
sqlx::query("PRAGMA journal_mode = WAL")
.execute(connection)
.await?;
Ok(())
}
.await;
result.map_err(|error| match init_error_tx.try_send(error) {
// The opener owns the original error and cancels connection retries.
Ok(()) => Error::PoolClosed,
// After opening, lazy connections retain SQLx's normal error handling.
Err(error) => error.into_inner(),
})
})
},
);
tokio::select! {
biased;
Some(error) = init_error_rx.recv() => Err(error),
result = pool_options.connect_with(options.clone()) => result,
}
};
let pool = retry_on_busy(|| async { Ok(connect().await?) }).await?;
// Each failed attempt closes its pool before initialization is retried.
let connect = || async { connect_once(&options).await.map_err(anyhow::Error::from) };
let pool = retry_on_busy(connect).await?;
let validation_result = self
.quick_check_manager
.quick_check_once(
@@ -444,7 +396,7 @@ impl SqliteConfig {
);
}
return retry_on_busy(|| async { Ok(connect().await?) }).await;
return retry_on_busy(connect).await;
}
}
@@ -472,6 +424,90 @@ impl SqliteConfig {
}
}
async fn connect_once(options: &SqliteConnectOptions) -> Result<SqlitePool, Error> {
// SQLx retries after_connect errors, eventually replacing them with PoolTimedOut.
// Return the first initialization error directly while opening this pool.
let (init_error_tx, mut init_error_rx) = tokio::sync::mpsc::channel(/*buffer*/ 1);
let pool_options =
SqlitePoolOptions::new()
.max_connections(5)
.after_connect(move |connection, _metadata| {
let init_error_tx = init_error_tx.clone();
Box::pin(async move {
let result = async {
let journal: String = sqlx::query_scalar("PRAGMA journal_mode")
.fetch_one(&mut *connection)
.await?;
if journal == "delete" {
// If the filesystem honors sync requests, EXTRA syncs the directory
// after SQLite deletes a rollback journal.
sqlx::query("PRAGMA synchronous = EXTRA")
.execute(connection)
.await?;
}
Ok(())
}
.await;
result.map_err(|error| match init_error_tx.try_send(error) {
// The opener owns the original error and cancels connection retries.
Ok(()) => Error::PoolClosed,
// After opening, lazy connections retain SQLx's normal error handling.
Err(error) => error.into_inner(),
})
})
});
let pool = tokio::select! {
biased;
Some(error) = init_error_rx.recv() => Err(error),
result = pool_options.connect_with(options.clone()) => result,
}?;
// Database settings persist on disk.
// Initialize these settings once before callers can use the pool.
// Connections that open later need only settings that apply to each connection.
let initialized: Result<(), Error> = async {
let mut connection = pool.acquire().await?;
// Auto-vacuum alone can write a header to disk.
// If creation stops before the schema exists, startup must initialize the file.
let empty =
sqlx::query_scalar::<_, bool>("SELECT NOT EXISTS (SELECT 1 FROM sqlite_schema)")
.fetch_one(&mut *connection)
.await?;
if empty {
let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
.fetch_one(&mut *connection)
.await?;
if mode == 0 {
sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
.execute(&mut *connection)
.await?;
}
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&mut *connection)
.await?;
}
// Another process can initialize WAL after this connection selects EXTRA.
// Apply NORMAL to WAL even if that process already created the schema.
let journal: String = sqlx::query_scalar("PRAGMA journal_mode")
.fetch_one(&mut *connection)
.await?;
if journal == "wal" {
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&mut *connection)
.await?;
}
// Wait for the connection to return to the pool before startup requests it again.
// An asynchronous drop can otherwise cause the pool to open a second connection.
connection.return_to_pool().await;
Ok(())
}
.await;
if let Err(error) = initialized {
pool.close().await;
return Err(error);
}
Ok(pool)
}
#[cfg(test)]
#[path = "sqlite_tests.rs"]
mod tests;