use std::path::PathBuf;
use std::time::Duration;
use deadpool::Runtime;
use deadpool::managed::{Manager, Metrics, RecycleError, RecycleResult};
use rusqlite::Connection;
use rusqlite::vtab::array;
use super::errors::SqliteStoreError;
deadpool::managed_reexports!(
"miden-client-sqlite-store",
SqlitePoolManager,
deadpool::managed::Object<SqlitePoolManager>,
rusqlite::Error,
SqliteStoreError
);
const RUNTIME: Runtime = Runtime::Tokio1;
pub struct SqlitePoolManager {
database_path: PathBuf,
}
impl SqlitePoolManager {
pub fn new(database_path: PathBuf) -> Self {
Self { database_path }
}
fn new_connection(&self) -> rusqlite::Result<Connection> {
let conn = Connection::open(&self.database_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
for suffix in &["", "-wal", "-shm"] {
let mut path = self.database_path.as_os_str().to_owned();
path.push(suffix);
let path = std::path::PathBuf::from(path);
if path.exists()
&& let Err(e) = std::fs::set_permissions(&path, perms.clone())
{
tracing::warn!(
path = %path.display(),
error = %e,
"failed to restrict permissions on the database file"
);
}
}
}
array::load_module(&conn)?;
conn.busy_timeout(Duration::from_secs(5))?;
let journal_mode: String =
conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))?;
if !journal_mode.eq_ignore_ascii_case("wal") {
tracing::warn!(
journal_mode = %journal_mode,
path = %self.database_path.display(),
"database does not support WAL journal mode; commits will be slower and readers \
will block on writes"
);
}
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
Ok(conn)
}
}
impl Manager for SqlitePoolManager {
type Type = deadpool_sync::SyncWrapper<Connection>;
type Error = rusqlite::Error;
async fn create(&self) -> Result<Self::Type, Self::Error> {
let conn = self.new_connection();
deadpool_sync::SyncWrapper::new(RUNTIME, move || conn).await
}
async fn recycle(&self, conn: &mut Self::Type, _: &Metrics) -> RecycleResult<Self::Error> {
if conn.is_mutex_poisoned() {
return Err(RecycleError::message("sqlite connection mutex is poisoned"));
}
conn.interact(|conn| {
if conn.is_autocommit() {
Ok(())
} else {
conn.execute_batch("ROLLBACK")
}
})
.await
.map_err(|_| RecycleError::message("failed to reset the sqlite connection"))??;
Ok(())
}
}
#[cfg(test)]
mod tests {
use miden_client::store::{SettingScope, Store};
use miden_client::testing::common::create_test_store_path;
use rusqlite::params;
use crate::SqliteStore;
use crate::sql_error::SqlResultExt;
use crate::tests::create_test_store;
#[tokio::test]
async fn connection_pragmas_are_applied() -> anyhow::Result<()> {
let store = create_test_store().await;
let (journal_mode, synchronous, busy_timeout, foreign_keys) = store
.interact_with_connection(|conn| {
let journal_mode: String = conn
.pragma_query_value(None, "journal_mode", |row| row.get(0))
.into_store_error()?;
let synchronous: i32 = conn
.pragma_query_value(None, "synchronous", |row| row.get(0))
.into_store_error()?;
let busy_timeout: i32 = conn
.pragma_query_value(None, "busy_timeout", |row| row.get(0))
.into_store_error()?;
let foreign_keys: i32 = conn
.pragma_query_value(None, "foreign_keys", |row| row.get(0))
.into_store_error()?;
Ok((journal_mode, synchronous, busy_timeout, foreign_keys))
})
.await?;
assert_eq!(journal_mode.to_lowercase(), "wal");
assert_eq!(synchronous, 1);
assert_eq!(busy_timeout, 5_000);
assert_eq!(foreign_keys, 1);
Ok(())
}
#[tokio::test]
async fn poisoned_connection_is_replaced() -> anyhow::Result<()> {
let store = create_test_store().await;
let panicked = store
.interact_with_connection(|_| -> Result<(), miden_client::store::StoreError> {
panic!("poisoning the connection on purpose")
})
.await;
assert!(panicked.is_err());
store
.set_setting(SettingScope::User, "after-panic".to_string(), b"value".to_vec())
.await?;
assert_eq!(
store.get_setting(SettingScope::User, "after-panic".to_string()).await?,
Some(b"value".to_vec())
);
Ok(())
}
#[tokio::test]
async fn leaked_transaction_is_rolled_back() -> anyhow::Result<()> {
let store = create_test_store().await;
store
.interact_with_connection(|conn| {
conn.execute_batch("BEGIN").into_store_error()?;
conn.execute(
"INSERT INTO settings (scope, name, value) VALUES (?1, ?2, ?3)",
params![SettingScope::User.as_u8(), "leaked", b"\x00".as_slice()],
)
.into_store_error()?;
Ok(())
})
.await?;
let autocommit = store.interact_with_connection(|conn| Ok(conn.is_autocommit())).await?;
assert!(autocommit, "the leaked transaction was not rolled back");
assert_eq!(store.get_setting(SettingScope::User, "leaked".to_string()).await?, None);
Ok(())
}
#[tokio::test]
async fn overlapping_accessors_wait_instead_of_failing() -> anyhow::Result<()> {
let path = create_test_store_path();
let first = SqliteStore::new(path.clone()).await?;
let second = SqliteStore::new(path).await?;
first
.set_setting(SettingScope::User, "from-first".to_string(), b"1".to_vec())
.await?;
second
.set_setting(SettingScope::User, "from-second".to_string(), b"2".to_vec())
.await?;
assert_eq!(
second.get_setting(SettingScope::User, "from-first".to_string()).await?,
Some(b"1".to_vec())
);
assert_eq!(
first.get_setting(SettingScope::User, "from-second".to_string()).await?,
Some(b"2".to_vec())
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn non_utf8_database_path_is_rejected() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let mut path = create_test_store_path();
let mut file_name =
path.file_name().expect("test path has a file name").as_bytes().to_vec();
file_name.push(0xff);
path.set_file_name(OsStr::from_bytes(&file_name));
let Err(error) = SqliteStore::new(path).await else {
panic!("a non-UTF-8 path must be rejected");
};
assert!(error.to_string().contains("not valid UTF-8"), "unexpected error: {error}");
}
}