use std::sync::{Arc, Mutex};
use helix_core::PortError;
use rusqlite::{Connection, OpenFlags};
use tokio::sync::Semaphore;
pub(super) const READ_POOL_SIZE: usize = 4;
pub(super) struct ReadPool {
pub(super) permits: Arc<Semaphore>,
pub(super) idle: Mutex<Vec<Connection>>,
}
impl ReadPool {
pub(super) fn new(conns: Vec<Connection>) -> Self {
Self {
permits: Arc::new(Semaphore::new(READ_POOL_SIZE)),
idle: Mutex::new(conns),
}
}
}
pub(super) fn open_writer(target: &str) -> Result<Connection, PortError> {
let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX;
Connection::open_with_flags(target, flags).map_err(map_sqlite_err)
}
pub(super) fn open_reader(target: &str) -> Result<Connection, PortError> {
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX;
let reader_target = read_only_target(target);
let conn = Connection::open_with_flags(reader_target, flags).map_err(map_sqlite_err)?;
conn.busy_timeout(std::time::Duration::from_millis(5000))
.map_err(map_sqlite_err)?;
Ok(conn)
}
fn read_only_target(target: &str) -> String {
let Some((path, query)) = target.split_once('?') else {
return target.to_string();
};
let read_query = query_without_access_mode(query);
if read_query.is_empty() {
path.to_string()
} else {
format!("{path}?{}", read_query.join("&"))
}
}
pub(super) fn target_supports_shared_readers(target: &str) -> bool {
let is_memory = target.contains(":memory:") || target.contains("mode=memory");
if !is_memory {
return true;
}
target.contains("cache=shared")
}
pub(super) fn sqlite_target_from_url(db_url: &str) -> String {
let target = db_url.strip_prefix("sqlite:").unwrap_or(db_url);
let (path, query) = match target.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (target, None),
};
let normalized_path = normalize_sqlite_path(path);
let Some(query) = query else {
return normalized_path;
};
let writer_query = query_without_write_create_mode(query);
if writer_query.is_empty() {
return normalized_path;
}
let uri_path = if normalized_path.starts_with("file:") {
normalized_path
} else if normalized_path == ":memory:" {
"file::memory:".to_string()
} else {
format!("file:{normalized_path}")
};
format!("{uri_path}?{}", writer_query.join("&"))
}
fn query_without_access_mode(query: &str) -> Vec<&str> {
query
.split('&')
.filter(|part| {
part.split_once('=')
.map_or(true, |(key, value)| key != "mode" || value == "memory")
})
.collect()
}
fn query_without_write_create_mode(query: &str) -> Vec<&str> {
query
.split('&')
.filter(|part| {
part.split_once('=')
.map_or(true, |(key, value)| !(key == "mode" && value == "rwc"))
})
.collect()
}
fn normalize_sqlite_path(path: &str) -> String {
let slash_path =
if path.starts_with("file:") || is_windows_drive_path(path) || path.starts_with("\\\\") {
path.replace('\\', "/")
} else {
path.to_string()
};
if let Some(file_path) = slash_path.strip_prefix("file:") {
if is_windows_drive_path(file_path.trim_start_matches('/')) {
return format!("file:{}", file_path.trim_start_matches('/'));
}
return slash_path;
}
if is_windows_drive_path(slash_path.trim_start_matches('/')) {
return format!("file:{}", slash_path.trim_start_matches('/'));
}
if slash_path.starts_with("///") {
let without_slashes = slash_path.trim_start_matches('/');
if is_windows_drive_path(without_slashes) {
return format!("file:{without_slashes}");
}
return format!("/{without_slashes}");
}
if slash_path.starts_with("//") {
let without_slashes = slash_path.trim_start_matches('/');
if is_windows_drive_path(without_slashes) {
return format!("file:{without_slashes}");
}
return format!("file://{without_slashes}");
}
slash_path
}
fn is_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}
pub(super) fn map_sqlite_err(e: impl std::fmt::Display) -> PortError {
PortError::Storage(e.to_string())
}
pub(super) fn map_lock_err<T>(_: std::sync::PoisonError<T>) -> PortError {
PortError::Storage("sqlite connection lock poisoned".to_string())
}
pub(super) fn map_join_err(e: tokio::task::JoinError) -> PortError {
PortError::Storage(format!("sqlite blocking worker failed: {e}"))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{open_reader, open_writer, read_only_target, sqlite_target_from_url};
fn temp_db_path(label: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"helix-storage-{label}-{}-{sequence}.db",
std::process::id(),
));
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("db-wal"));
let _ = std::fs::remove_file(path.with_extension("db-shm"));
path
}
fn remove_temp_db(path: &Path) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(path.with_extension("db-wal"));
let _ = std::fs::remove_file(path.with_extension("db-shm"));
}
#[test]
fn read_only_target_drops_write_mode_but_keeps_other_uri_options() {
assert_eq!(
read_only_target("file:/tmp/tenant.db?mode=rwc"),
"file:/tmp/tenant.db"
);
assert_eq!(
read_only_target("file:/tmp/tenant.db?mode=rwc&cache=shared"),
"file:/tmp/tenant.db?cache=shared"
);
assert_eq!(
read_only_target("file:/tmp/tenant.db?cache=shared&mode=rwc"),
"file:/tmp/tenant.db?cache=shared"
);
assert_eq!(
read_only_target("file::memory:?mode=memory&cache=shared"),
"file::memory:?mode=memory&cache=shared"
);
assert_eq!(
read_only_target("file:/tmp/tenant.db?mode=ro"),
"file:/tmp/tenant.db"
);
assert_eq!(
read_only_target("file:/tmp/tenant.db?mode=rw"),
"file:/tmp/tenant.db"
);
assert_eq!(
read_only_target("file:/tmp/tenant.db"),
"file:/tmp/tenant.db"
);
}
#[test]
fn sqlite_target_from_url_normalizes_uri_and_windows_paths() {
assert_eq!(
sqlite_target_from_url("sqlite:/tmp/tenant.db?mode=rwc"),
"/tmp/tenant.db"
);
assert_eq!(
sqlite_target_from_url("sqlite:///tmp/tenant.db?mode=rwc&cache=shared"),
"file:/tmp/tenant.db?cache=shared"
);
assert_eq!(
sqlite_target_from_url("sqlite:file:/tmp/tenant.db?mode=rwc"),
"file:/tmp/tenant.db"
);
assert_eq!(
sqlite_target_from_url("sqlite:file:/tmp/tenant.db?mode=ro"),
"file:/tmp/tenant.db?mode=ro"
);
assert_eq!(
sqlite_target_from_url("sqlite:file:/tmp/tenant.db?mode=rw"),
"file:/tmp/tenant.db?mode=rw"
);
assert_eq!(
sqlite_target_from_url("sqlite:///C:/Users/venus/tenant.db?mode=rwc"),
"file:C:/Users/venus/tenant.db"
);
assert_eq!(
sqlite_target_from_url("sqlite:file:C:\\Users\\venus\\tenant.db?mode=rwc"),
"file:C:/Users/venus/tenant.db"
);
assert_eq!(
sqlite_target_from_url("sqlite:C:\\Users\\venus\\tenant.db?mode=rwc"),
"file:C:/Users/venus/tenant.db"
);
assert_eq!(
sqlite_target_from_url("sqlite:\\\\server\\share\\tenant.db?mode=rwc"),
"file://server/share/tenant.db"
);
assert_eq!(
sqlite_target_from_url("sqlite://server/share/tenant.db?mode=rwc"),
"file://server/share/tenant.db"
);
assert_eq!(
sqlite_target_from_url("sqlite::memory:?cache=shared"),
"file::memory:?cache=shared"
);
assert_eq!(
sqlite_target_from_url("sqlite:file:server/share/tenant.db?mode=rwc"),
"file:server/share/tenant.db"
);
}
#[test]
fn writer_and_reader_share_sqlite_scheme_path_for_mode_rwc() {
let path = temp_db_path("uri");
let raw_url = format!("sqlite:{}?mode=rwc", path.display());
let target = sqlite_target_from_url(&raw_url);
let writer = open_writer(&target).expect("writer should open canonical target");
writer
.execute_batch(
"CREATE TABLE sample (value INTEGER NOT NULL); INSERT INTO sample VALUES (42);",
)
.expect("writer should create and populate the table");
let reader = open_reader(&target).expect("reader should open the same canonical target");
let value: i64 = reader
.query_row("SELECT value FROM sample", [], |row| row.get(0))
.expect("reader should see writer data");
assert_eq!(value, 42);
drop(reader);
drop(writer);
remove_temp_db(&path);
}
}