use crossbeam_queue::ArrayQueue;
use parking_lot::{Condvar, Mutex};
use rusqlite::hooks::{AuthContext, Authorization};
use rusqlite::{Connection, OpenFlags};
use sha2::{Digest, Sha256};
use std::cell::Cell;
use std::collections::HashMap;
use std::fs;
use std::io::Read as _;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use crate::error::SqliteError;
use crate::writer_task::WriterTaskHandle;
use khive_storage::error::StorageError;
use khive_storage::tx_registry::{DbIdentity, TxOrigin};
use khive_storage::StorageCapability;
const CACHE_SIZE_KIB: &str = "-65536";
const MMAP_SIZE_BYTES: &str = "1073741824";
const DEFAULT_READER_CAP: usize = 8;
const DEFAULT_JOURNAL_SIZE_LIMIT_BYTES: i64 = 67_108_864; const DEFAULT_WRITE_QUEUE_CAPACITY: usize = 256;
static NEXT_MAIN_POOL_GENERATION: AtomicU64 = AtomicU64::new(1);
struct OpenPoolIdentity {
count: usize,
basename: String,
suffix: String,
}
#[derive(Default)]
struct PoolIdentityRegistry {
paths: HashMap<PathBuf, OpenPoolIdentity>,
}
fn pool_identity_registry() -> &'static Mutex<PoolIdentityRegistry> {
static REGISTRY: OnceLock<Mutex<PoolIdentityRegistry>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(PoolIdentityRegistry::default()))
}
fn pool_identity_suffix(path: &Path) -> String {
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().to_vec()
};
#[cfg(windows)]
let bytes = {
use std::os::windows::ffi::OsStrExt;
path.as_os_str()
.encode_wide()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>()
};
#[cfg(not(any(unix, windows)))]
let bytes = path.to_string_lossy().as_bytes().to_vec();
let digest = Sha256::digest(&bytes);
format!(
"{:02x}{:02x}{:02x}{:02x}",
digest[0], digest[1], digest[2], digest[3]
)
}
struct PoolIdentityRegistration(PathBuf);
impl PoolIdentityRegistration {
fn new(path: &Path) -> Self {
let mut registry = pool_identity_registry().lock();
if let Some(entry) = registry.paths.get_mut(path) {
entry.count += 1;
} else {
let basename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let suffix = pool_identity_suffix(path);
registry.paths.insert(
path.to_path_buf(),
OpenPoolIdentity {
count: 1,
basename,
suffix,
},
);
}
Self(path.to_path_buf())
}
fn label(&self) -> String {
let registry = pool_identity_registry().lock();
let entry = ®istry.paths[&self.0];
let collides = registry
.paths
.iter()
.any(|(path, other)| path != &self.0 && other.basename == entry.basename);
if collides {
format!("{}#{}", entry.basename, entry.suffix)
} else {
entry.basename.clone()
}
}
}
impl Drop for PoolIdentityRegistration {
fn drop(&mut self) {
let mut registry = pool_identity_registry().lock();
if let Some(entry) = registry.paths.get_mut(&self.0) {
entry.count -= 1;
if entry.count == 0 {
registry.paths.remove(&self.0);
}
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum RuntimeWriteOperation {
MergeEntity,
MergeNote,
UpdateSymmetricEdge,
}
impl RuntimeWriteOperation {
fn operation(self) -> &'static str {
match self {
Self::MergeEntity => "merge_entity",
Self::MergeNote => "merge_note",
Self::UpdateSymmetricEdge => "update_edge",
}
}
fn fallback_site(self) -> crate::timeout_sink::Site {
match self {
Self::MergeEntity => crate::timeout_sink::Site::DirectRouteRuntimeMergeEntity,
Self::MergeNote => crate::timeout_sink::Site::DirectRouteRuntimeMergeNote,
Self::UpdateSymmetricEdge => {
crate::timeout_sink::Site::DirectRouteRuntimeUpdateSymmetricEdge
}
}
}
}
pub(crate) const FALLBACK_WAL_AUTOCHECKPOINT_PAGES: u32 = 4_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CheckpointOwnership {
Unclaimed,
Claiming,
Claimed,
}
struct CheckpointOwnershipState {
phase: CheckpointOwnership,
#[cfg(test)]
connection_waiters: usize,
}
#[cfg(test)]
struct CheckpointConnectionConfigPause {
selected: std::sync::Barrier,
resume: std::sync::Barrier,
}
#[cfg(test)]
impl CheckpointConnectionConfigPause {
fn new() -> Self {
Self {
selected: std::sync::Barrier::new(2),
resume: std::sync::Barrier::new(2),
}
}
}
struct CheckpointOwnershipGate {
state: Mutex<CheckpointOwnershipState>,
changed: Condvar,
#[cfg(test)]
connection_config_pause: Mutex<Option<Arc<CheckpointConnectionConfigPause>>>,
#[cfg(test)]
claim_lock_observed: Mutex<Option<std::sync::mpsc::SyncSender<bool>>>,
}
impl CheckpointOwnershipGate {
fn new() -> Self {
Self {
state: Mutex::new(CheckpointOwnershipState {
phase: CheckpointOwnership::Unclaimed,
#[cfg(test)]
connection_waiters: 0,
}),
changed: Condvar::new(),
#[cfg(test)]
connection_config_pause: Mutex::new(None),
#[cfg(test)]
claim_lock_observed: Mutex::new(None),
}
}
fn begin_claim(&self) -> bool {
#[cfg(test)]
let claim_lock_observed = self.claim_lock_observed.lock().take();
#[cfg(test)]
let mut state = if let Some(observed) = claim_lock_observed {
match self.state.try_lock() {
Some(state) => {
let _ = observed.send(false);
state
}
None => {
let _ = observed.send(true);
self.state.lock()
}
}
} else {
self.state.lock()
};
#[cfg(not(test))]
let mut state = self.state.lock();
loop {
match state.phase {
CheckpointOwnership::Unclaimed => {
state.phase = CheckpointOwnership::Claiming;
self.changed.notify_all();
return true;
}
CheckpointOwnership::Claiming => self.changed.wait(&mut state),
CheckpointOwnership::Claimed => return false,
}
}
}
fn finish_claim(&self, succeeded: bool) {
let mut state = self.state.lock();
debug_assert_eq!(state.phase, CheckpointOwnership::Claiming);
state.phase = if succeeded {
CheckpointOwnership::Claimed
} else {
CheckpointOwnership::Unclaimed
};
self.changed.notify_all();
}
fn settled_state(&self) -> parking_lot::MutexGuard<'_, CheckpointOwnershipState> {
let mut state = self.state.lock();
while state.phase == CheckpointOwnership::Claiming {
#[cfg(test)]
{
state.connection_waiters += 1;
self.changed.notify_all();
}
self.changed.wait(&mut state);
#[cfg(test)]
{
state.connection_waiters -= 1;
self.changed.notify_all();
}
}
state
}
#[cfg(test)]
fn wal_autocheckpoint_pages(&self) -> u32 {
let state = self.settled_state();
match state.phase {
CheckpointOwnership::Unclaimed => FALLBACK_WAL_AUTOCHECKPOINT_PAGES,
CheckpointOwnership::Claimed => 0,
CheckpointOwnership::Claiming => unreachable!("claim wait must settle the state"),
}
}
fn configure_wal_autocheckpoint(&self, conn: &Connection) -> Result<(), SqliteError> {
let state = self.settled_state();
let pages = match state.phase {
CheckpointOwnership::Unclaimed => FALLBACK_WAL_AUTOCHECKPOINT_PAGES,
CheckpointOwnership::Claimed => 0,
CheckpointOwnership::Claiming => unreachable!("claim wait must settle the state"),
};
#[cfg(test)]
if let Some(pause) = self.connection_config_pause.lock().take() {
pause.selected.wait();
pause.resume.wait();
}
conn.pragma_update(None, "wal_autocheckpoint", pages)?;
drop(state);
Ok(())
}
}
fn deny_retired_writer(_context: AuthContext<'_>) -> Authorization {
Authorization::Deny
}
pub(crate) const TEST_HARNESS_ENV: &str = "KHIVE_TEST_HARNESS";
#[derive(Clone, Debug)]
pub struct PoolConfig {
pub path: Option<PathBuf>,
pub max_readers: usize,
pub wal_mode: bool,
pub busy_timeout: Duration,
pub checkout_timeout: Duration,
pub journal_size_limit_bytes: i64,
pub read_only: bool,
pub write_queue_enabled: Option<bool>,
pub write_queue_capacity: usize,
pub write_routing_strict: bool,
pub write_admission_deadline_ms: u64,
pub read_tx_max_age: Duration,
}
const WRITE_ADMISSION_DEADLINE_MS_RANGE: std::ops::RangeInclusive<u64> = 100..=10_000;
const DEFAULT_WRITE_ADMISSION_DEADLINE_MS: u64 = 2000;
impl Default for PoolConfig {
fn default() -> Self {
Self {
path: None,
max_readers: std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.clamp(1, DEFAULT_READER_CAP),
wal_mode: true,
busy_timeout: Duration::from_secs(
std::env::var("KHIVE_BUSY_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30),
),
checkout_timeout: Duration::from_secs(
std::env::var("KHIVE_CHECKOUT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5),
),
journal_size_limit_bytes: std::env::var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(DEFAULT_JOURNAL_SIZE_LIMIT_BYTES),
read_only: false,
write_queue_enabled: std::env::var_os("KHIVE_WRITE_QUEUE").map(|v| {
v.to_str()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}),
write_queue_capacity: std::env::var("KHIVE_WRITE_QUEUE_CAPACITY")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_WRITE_QUEUE_CAPACITY),
write_routing_strict: std::env::var("KHIVE_WRITE_ROUTING")
.map(|v| v.eq_ignore_ascii_case("strict"))
.unwrap_or(false),
write_admission_deadline_ms: std::env::var("KHIVE_WRITE_ADMISSION_DEADLINE_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_WRITE_ADMISSION_DEADLINE_MS),
read_tx_max_age: crate::checkpoint::tx_age_thresholds_from_env(
Duration::from_secs(30),
Duration::from_secs(120),
)
.1,
}
}
}
#[cfg(any(test, feature = "test-support"))]
impl PoolConfig {
pub fn for_test() -> Self {
Self {
max_readers: 2,
..Self::default()
}
}
}
fn refuse_home_data_store_in_tests(config: &PoolConfig) -> Result<(), SqliteError> {
if std::env::var(TEST_HARNESS_ENV).as_deref() != Ok("1") {
return Ok(());
}
let Some(path) = config.path.as_deref() else {
return Ok(());
};
if path
.as_os_str()
.as_encoded_bytes()
.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"file:"))
{
return Err(SqliteError::InvalidData(format!(
"test harness refused SQLite URI database path {}; use a filesystem path outside \
HOME/.khive (deliberate sessions against a real store run the built binary \
directly, outside the Cargo test environment)",
path.display()
)));
}
let Some(home) = std::env::var_os("HOME") else {
return Ok(());
};
let canonical_path = canonicalize_deepest_existing(path)?;
let canonical_home_data_dir =
canonicalize_deepest_existing(&PathBuf::from(home).join(".khive"))?;
if canonical_path.starts_with(&canonical_home_data_dir) {
return Err(SqliteError::InvalidData(format!(
"test harness refused to open SQLite database under HOME/.khive: {} \
(deliberate sessions against a real store run the built binary directly, \
outside the Cargo test environment)",
canonical_path.display()
)));
}
Ok(())
}
fn canonicalize_deepest_existing(path: &Path) -> Result<PathBuf, SqliteError> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_err(SqliteError::Io)?.join(path)
};
for ancestor in absolute.ancestors() {
match fs::canonicalize(ancestor) {
Ok(mut canonical) => {
let missing = absolute.strip_prefix(ancestor).map_err(|error| {
SqliteError::InvalidData(format!(
"failed to preserve missing path components for {}: {error}",
absolute.display()
))
})?;
canonical.push(missing);
return Ok(canonical);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(SqliteError::InvalidData(format!(
"failed to canonicalize database path ancestor {}: {error}",
ancestor.display()
)));
}
}
}
Err(SqliteError::InvalidData(format!(
"database path has no canonicalizable ancestor: {}",
absolute.display()
)))
}
fn validate_write_admission_deadline(deadline_ms: u64) -> Result<(), SqliteError> {
if WRITE_ADMISSION_DEADLINE_MS_RANGE.contains(&deadline_ms) {
return Ok(());
}
Err(SqliteError::InvalidConfig(format!(
"write_admission_deadline_ms must be in [{}, {}] ms, got {deadline_ms}",
WRITE_ADMISSION_DEADLINE_MS_RANGE.start(),
WRITE_ADMISSION_DEADLINE_MS_RANGE.end()
)))
}
pub struct ConnectionPool {
writer: Arc<Mutex<Connection>>,
main_pool_generation: OnceLock<u64>,
checkpoint_ownership: CheckpointOwnershipGate,
pooled_writer_retired: AtomicBool,
writer_acquisition_counters: Arc<WriterAcquisitionCounters>,
reader_acquisition_counters: ReaderAcquisitionCounters,
readers: ArrayQueue<Connection>,
max_readers: usize,
config: PoolConfig,
read_only_open_target: Option<PathBuf>,
sql_bridge_reader_slots: Arc<Semaphore>,
sql_bridge_writer_slots: Arc<Semaphore>,
writer_task: OnceLock<Option<WriterTaskHandle>>,
writer_task_join: Mutex<Option<tokio::task::JoinHandle<()>>>,
writer_task_join_stored: AtomicBool,
origin: TxOrigin,
identity_path: Option<PathBuf>,
identity_registration: Option<PoolIdentityRegistration>,
#[cfg(test)]
writer_task_spawn_count: std::sync::atomic::AtomicUsize,
}
impl Drop for ConnectionPool {
fn drop(&mut self) {
while let Some(conn) = self.readers.pop() {
drop(conn);
}
}
}
enum ReaderLease<'pool> {
Pooled(Connection),
Shared(parking_lot::MutexGuard<'pool, Connection>),
}
pub struct ReaderRow<'row, 'statement> {
row: &'row rusqlite::Row<'statement>,
}
impl ReaderRow<'_, '_> {
pub fn get<I: rusqlite::RowIndex, T: rusqlite::types::FromSql>(
&self,
index: I,
) -> rusqlite::Result<T> {
self.row.get(index)
}
pub fn get_ref<I: rusqlite::RowIndex>(
&self,
index: I,
) -> rusqlite::Result<rusqlite::types::ValueRef<'_>> {
self.row.get_ref(index)
}
}
struct ReaderQueryInProgress<'a>(&'a Cell<bool>);
impl Drop for ReaderQueryInProgress<'_> {
fn drop(&mut self) {
self.0.set(false);
}
}
pub struct ReaderGuard<'pool> {
lease: Option<ReaderLease<'pool>>,
admission_slot: Option<tokio::sync::OwnedSemaphorePermit>,
pool: &'pool ConnectionPool,
reusable: Cell<bool>,
query_in_progress: Cell<bool>,
checked_out_at: Instant,
dirty: Cell<bool>,
operation: Option<&'static str>,
}
impl<'pool> ReaderGuard<'pool> {
pub(crate) fn conn(&self) -> &Connection {
match self
.lease
.as_ref()
.expect("reader guard missing connection")
{
ReaderLease::Pooled(conn) => conn,
ReaderLease::Shared(guard) => guard,
}
}
pub fn query_row<T, P, F>(&self, sql: &str, params: P, f: F) -> Result<T, SqliteError>
where
P: rusqlite::Params,
F: FnOnce(&ReaderRow<'_, '_>) -> rusqlite::Result<T>,
{
crate::sql_bridge::reader_capability_admits(sql).map_err(SqliteError::InvalidData)?;
if !self.reusable.get() {
return Err(SqliteError::InvalidData(
"reader lease is quarantined after failed read cleanup".into(),
));
}
if self.query_in_progress.replace(true) {
return Err(SqliteError::InvalidData(
"reader lease is already executing a query".into(),
));
}
let _in_progress = ReaderQueryInProgress(&self.query_in_progress);
self.mark_dirty();
crate::read_cancellation::run_borrowed_reader(self, |conn, admission| {
conn.query_row(sql, params, |row| {
if !admission.admits() {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_INTERRUPT),
Some("request stopped before the mapper".into()),
));
}
f(&ReaderRow { row })
})
.map_err(|error| {
StorageError::driver(StorageCapability::Sql, "reader_guard.query_row", error)
})
})
.map_err(|error| match error {
StorageError::Driver {
capability,
operation,
source,
} => match source.downcast::<rusqlite::Error>() {
Ok(error) => SqliteError::Rusqlite(*error),
Err(source) => SqliteError::RequestReadStopped(StorageError::Driver {
capability,
operation,
source,
}),
},
other => SqliteError::RequestReadStopped(other),
})
}
pub(crate) fn discard(&self) {
self.reusable.set(false);
}
pub(crate) fn mark_dirty(&self) {
self.dirty.set(true);
}
pub(crate) fn label_operation(&mut self, operation: &'static str) {
self.operation = Some(operation);
}
}
impl<'pool> Drop for ReaderGuard<'pool> {
fn drop(&mut self) {
let Some(lease) = self.lease.take() else {
return;
};
match lease {
ReaderLease::Pooled(conn) if self.reusable.get() => {
self.pool.return_reader(conn, self.dirty.get())
}
ReaderLease::Pooled(conn) => {
close_connection_quietly(conn);
self.pool.replace_discarded_reader_slot();
}
ReaderLease::Shared(guard) if !self.reusable.get() => {
self.pool.retire_pooled_writer(&guard);
}
ReaderLease::Shared(guard) => {
if self.dirty.get() && !restore_shared_reader_state(&guard, &self.pool.config) {
self.pool.retire_pooled_writer(&guard);
}
}
}
drop(self.admission_slot.take());
self.pool
.reader_acquisition_counters
.record_checkout_completed(self.checked_out_at.elapsed(), self.operation);
}
}
pub(crate) struct SharedReaderTransactionGuard {
conn: parking_lot::ArcMutexGuard<parking_lot::RawMutex, Connection>,
admission_slot: Option<tokio::sync::OwnedSemaphorePermit>,
pool: Arc<ConnectionPool>,
checked_out_at: Instant,
poison: Cell<bool>,
}
impl SharedReaderTransactionGuard {
pub(crate) fn conn(&self) -> &Connection {
&self.conn
}
pub(crate) fn poison(&self) {
self.poison.set(true);
}
}
impl Drop for SharedReaderTransactionGuard {
fn drop(&mut self) {
let mut restored = !self.poison.get();
if restored && !self.conn.is_autocommit() {
restored = self.conn.execute_batch("ROLLBACK").is_ok() && self.conn.is_autocommit();
}
if restored {
restored = restore_shared_reader_state(&self.conn, &self.pool.config);
}
if !restored {
self.pool.retire_pooled_writer(&self.conn);
}
drop(self.admission_slot.take());
self.pool
.reader_acquisition_counters
.record_checkout_completed(
self.checked_out_at.elapsed(),
Some("explicit_sql_read_transaction"),
);
}
}
impl ConnectionPool {
pub(crate) fn checkout_shared_reader_transaction(
self: &Arc<Self>,
should_stop: impl Fn() -> bool,
) -> Result<Option<SharedReaderTransactionGuard>, SqliteError> {
debug_assert_eq!(
self.max_readers, 0,
"the owned shared-reader-transaction guard exists only for the degraded, \
single-connection backend"
);
self.ensure_pooled_writer_active()?;
let started = Instant::now();
let admission_slot = loop {
if should_stop() {
return Ok(None);
}
match Arc::clone(&self.sql_bridge_reader_slots).try_acquire_owned() {
Ok(slot) => break slot,
Err(tokio::sync::TryAcquireError::Closed) => {
return Err(SqliteError::InvalidData(
"reader admission semaphore is closed".to_string(),
));
}
Err(tokio::sync::TryAcquireError::NoPermits) => {}
}
if started.elapsed() >= self.config.checkout_timeout {
self.reader_acquisition_counters.record_checkout_timeout();
return Err(pool_exhausted_error(
self.config.checkout_timeout,
self.max_readers,
));
}
thread::yield_now();
};
loop {
if should_stop() {
return Ok(None);
}
let remaining = self
.config
.checkout_timeout
.saturating_sub(started.elapsed());
if remaining.is_zero() {
self.reader_acquisition_counters.record_checkout_timeout();
return Err(pool_exhausted_error(
self.config.checkout_timeout,
self.max_readers,
));
}
if let Some(conn) = self
.writer
.try_lock_arc_for(remaining.min(Duration::from_millis(2)))
{
self.ensure_pooled_writer_active()?;
self.reader_acquisition_counters.record_pooled_checkout();
return Ok(Some(SharedReaderTransactionGuard {
conn,
admission_slot: Some(admission_slot),
pool: Arc::clone(self),
checked_out_at: Instant::now(),
poison: Cell::new(false),
}));
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] pub(crate) enum StandaloneReaderPurpose {
ExplicitSqlReadTransaction,
BootSchemaProbe,
DiagnosticsIndependentSnapshot,
}
impl StandaloneReaderPurpose {
fn is_infrastructure(self) -> bool {
matches!(
self,
Self::BootSchemaProbe | Self::DiagnosticsIndependentSnapshot
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReaderAcquisitionSnapshot {
pub reader_admission_capacity: usize,
pub available_reader_admission_slots: usize,
pub acquisitions: u64,
pub pooled_checkouts: u64,
pub standalone_opens: u64,
pub infrastructure_standalone_opens: u64,
pub checkout_timeouts: u64,
pub active_pooled_checkouts: u64,
pub peak_active_pooled_checkouts: u64,
pub completed_pooled_checkouts: u64,
pub max_completed_hold_micros: u64,
pub max_completed_hold_operation: Option<&'static str>,
pub reader_replacement_open_failures: u64,
}
#[derive(Debug, Default, Clone, Copy)]
struct LongestCompletedHold {
micros: u64,
operation: Option<&'static str>,
}
#[derive(Debug, Default)]
struct ReaderAcquisitionCounters {
pooled_checkouts: AtomicU64,
standalone_opens: AtomicU64,
infrastructure_standalone_opens: AtomicU64,
checkout_timeouts: AtomicU64,
active_pooled_checkouts: AtomicU64,
peak_active_pooled_checkouts: AtomicU64,
completed_pooled_checkouts: AtomicU64,
longest_completed_hold: parking_lot::Mutex<LongestCompletedHold>,
reader_replacement_open_failures: AtomicU64,
}
impl ReaderAcquisitionCounters {
fn record_pooled_checkout(&self) {
self.pooled_checkouts.fetch_add(1, Ordering::Relaxed);
let active = self
.active_pooled_checkouts
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
self.peak_active_pooled_checkouts
.fetch_max(active, Ordering::Relaxed);
}
fn record_checkout_timeout(&self) {
self.checkout_timeouts.fetch_add(1, Ordering::Relaxed);
}
fn record_reader_replacement_open_failure(&self) {
self.reader_replacement_open_failures
.fetch_add(1, Ordering::Relaxed);
}
fn record_standalone_open(&self, purpose: StandaloneReaderPurpose) {
if purpose.is_infrastructure() {
self.infrastructure_standalone_opens
.fetch_add(1, Ordering::Relaxed);
} else {
self.standalone_opens.fetch_add(1, Ordering::Relaxed);
}
}
fn record_checkout_completed(&self, hold: Duration, operation: Option<&'static str>) {
let previous = self.active_pooled_checkouts.fetch_sub(1, Ordering::Relaxed);
debug_assert!(previous > 0, "reader active-checkout counter underflow");
self.completed_pooled_checkouts
.fetch_add(1, Ordering::Relaxed);
let micros = u64::try_from(hold.as_micros()).unwrap_or(u64::MAX);
let mut longest = self.longest_completed_hold.lock();
if micros > longest.micros {
longest.micros = micros;
longest.operation = operation;
}
}
fn snapshot(
&self,
reader_admission_capacity: usize,
available_reader_admission_slots: usize,
) -> ReaderAcquisitionSnapshot {
let pooled_checkouts = self.pooled_checkouts.load(Ordering::Relaxed);
let standalone_opens = self.standalone_opens.load(Ordering::Relaxed);
let longest_completed_hold = *self.longest_completed_hold.lock();
ReaderAcquisitionSnapshot {
reader_admission_capacity,
available_reader_admission_slots,
acquisitions: pooled_checkouts.saturating_add(standalone_opens),
pooled_checkouts,
standalone_opens,
infrastructure_standalone_opens: self
.infrastructure_standalone_opens
.load(Ordering::Relaxed),
checkout_timeouts: self.checkout_timeouts.load(Ordering::Relaxed),
active_pooled_checkouts: self.active_pooled_checkouts.load(Ordering::Relaxed),
peak_active_pooled_checkouts: self.peak_active_pooled_checkouts.load(Ordering::Relaxed),
completed_pooled_checkouts: self.completed_pooled_checkouts.load(Ordering::Relaxed),
max_completed_hold_micros: longest_completed_hold.micros,
max_completed_hold_operation: longest_completed_hold.operation,
reader_replacement_open_failures: self
.reader_replacement_open_failures
.load(Ordering::Relaxed),
}
}
}
pub struct WriterGuard<'pool> {
guard: parking_lot::MutexGuard<'pool, Connection>,
origin: TxOrigin,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WriterAcquisitionSnapshot {
pub acquisitions: u64,
pub pooled_acquisitions: u64,
pub standalone_acquisitions: u64,
pub writer_task_acquisitions: u64,
pub timeouts: u64,
pub writer_task_begin_busy: u64,
pub writer_task_begin_busy_absorbed: u64,
pub writer_task_begin_errors: u64,
pub writer_task_request_failures: u64,
pub writer_task_side_effects_unknown: u64,
}
#[derive(Debug, Default)]
pub(crate) struct WriterAcquisitionCounters {
pooled_acquisitions: AtomicU64,
standalone_acquisitions: AtomicU64,
writer_task_acquisitions: AtomicU64,
pooled_timeouts: AtomicU64,
writer_task_begin_busy: AtomicU64,
writer_task_begin_busy_absorbed: AtomicU64,
writer_task_begin_errors: AtomicU64,
writer_task_request_failures: AtomicU64,
writer_task_side_effects_unknown: AtomicU64,
}
impl WriterAcquisitionCounters {
pub(crate) fn record_writer_task_acquisition(&self) {
self.writer_task_acquisitions
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_writer_task_begin_busy(&self) {
self.writer_task_begin_busy.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_writer_task_begin_busy_absorbed(&self) {
self.writer_task_begin_busy_absorbed
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_writer_task_begin_error(&self) {
self.writer_task_begin_errors
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_writer_task_request_failure(&self) {
self.writer_task_request_failures
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_writer_task_side_effects_unknown(&self) {
self.writer_task_side_effects_unknown
.fetch_add(1, Ordering::Relaxed);
}
fn snapshot(&self) -> WriterAcquisitionSnapshot {
let pooled_acquisitions = self.pooled_acquisitions.load(Ordering::Relaxed);
let standalone_acquisitions = self.standalone_acquisitions.load(Ordering::Relaxed);
let writer_task_acquisitions = self.writer_task_acquisitions.load(Ordering::Relaxed);
WriterAcquisitionSnapshot {
acquisitions: pooled_acquisitions
.saturating_add(standalone_acquisitions)
.saturating_add(writer_task_acquisitions),
pooled_acquisitions,
standalone_acquisitions,
writer_task_acquisitions,
timeouts: self.pooled_timeouts.load(Ordering::Relaxed),
writer_task_begin_busy: self.writer_task_begin_busy.load(Ordering::Relaxed),
writer_task_begin_busy_absorbed: self
.writer_task_begin_busy_absorbed
.load(Ordering::Relaxed),
writer_task_begin_errors: self.writer_task_begin_errors.load(Ordering::Relaxed),
writer_task_request_failures: self.writer_task_request_failures.load(Ordering::Relaxed),
writer_task_side_effects_unknown: self
.writer_task_side_effects_unknown
.load(Ordering::Relaxed),
}
}
}
impl<'pool> WriterGuard<'pool> {
pub fn conn(&self) -> &Connection {
&self.guard
}
pub fn conn_mut(&mut self) -> &mut Connection {
&mut self.guard
}
pub fn transaction<F, R>(&self, f: F) -> Result<R, SqliteError>
where
F: FnOnce(&Connection) -> Result<R, SqliteError>,
{
self.guard.execute_batch("BEGIN IMMEDIATE")?;
let _tx_handle = khive_storage::tx_registry::register_scoped(
Some("writer_guard_tx".to_string()),
self.origin.clone(),
);
match f(&self.guard) {
Ok(result) => {
if let Err(err) = self.guard.execute_batch("COMMIT") {
let _ = self.guard.execute_batch("ROLLBACK");
return Err(err.into());
}
Ok(result)
}
Err(err) => {
let _ = self.guard.execute_batch("ROLLBACK");
Err(err)
}
}
}
}
impl<'pool> Deref for WriterGuard<'pool> {
type Target = Connection;
fn deref(&self) -> &Self::Target {
self.conn()
}
}
impl<'pool> DerefMut for WriterGuard<'pool> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.conn_mut()
}
}
impl ConnectionPool {
pub fn new(config: PoolConfig) -> Result<Self, SqliteError> {
refuse_home_data_store_in_tests(&config)?;
validate_write_admission_deadline(config.write_admission_deadline_ms)?;
let mut config = config;
let inert_memory_queue_request =
config.path.is_none() && config.write_queue_enabled == Some(true);
config.write_queue_enabled =
Some(config.write_queue_enabled.unwrap_or(config.path.is_some()));
if inert_memory_queue_request {
tracing::warn!(
"write queue explicitly requested for an in-memory pool; it is inert because \
in-memory pools cannot host a writer task"
);
}
let (origin, identity_path) = match config.path.as_ref() {
Some(path) => {
let (identity, canonical) = mint_db_identity(path)?;
(TxOrigin::Database(identity), Some(canonical))
}
None => (TxOrigin::Memory, None),
};
let read_only_open_target = read_only_open_target(&config, identity_path.as_deref())?;
let writer = open_writer_connection(&config, read_only_open_target.as_deref())?;
let wal_enabled = configure_writer_connection(&writer, &config)?;
let max_readers = effective_reader_count(&config, wal_enabled);
let readers = ArrayQueue::new(max_readers.max(1));
let mut pool = Self {
writer: Arc::new(Mutex::new(writer)),
main_pool_generation: OnceLock::new(),
checkpoint_ownership: CheckpointOwnershipGate::new(),
pooled_writer_retired: AtomicBool::new(false),
writer_acquisition_counters: Arc::new(WriterAcquisitionCounters::default()),
reader_acquisition_counters: ReaderAcquisitionCounters::default(),
readers,
max_readers,
config,
read_only_open_target,
sql_bridge_reader_slots: Arc::new(Semaphore::new(max_readers.max(1))),
sql_bridge_writer_slots: Arc::new(Semaphore::new(1)),
writer_task: OnceLock::new(),
writer_task_join: Mutex::new(None),
writer_task_join_stored: AtomicBool::new(false),
origin,
identity_path,
identity_registration: None,
#[cfg(test)]
writer_task_spawn_count: std::sync::atomic::AtomicUsize::new(0),
};
for _ in 0..pool.max_readers {
let conn = pool.open_reader_connection()?;
pool.readers
.push(conn)
.expect("reader queue must have capacity during pool initialization");
}
if !pool.config.read_only {
crate::timeout_sink::init(
pool.canonical_path().and_then(Path::parent),
&crate::timeout_sink::db_label(&pool),
);
}
pool.identity_registration = pool.canonical_path().map(PoolIdentityRegistration::new);
Ok(pool)
}
pub fn reader(&self) -> Result<ReaderGuard<'_>, SqliteError> {
self.reader_until(|| false)?.ok_or_else(|| {
SqliteError::InvalidData("uncancelled reader checkout stopped unexpectedly".into())
})
}
pub(crate) fn reader_until<C>(
&self,
should_stop: C,
) -> Result<Option<ReaderGuard<'_>>, SqliteError>
where
C: Fn() -> bool,
{
let started = Instant::now();
let mut admission_attempt = 0u32;
let admission_slot = loop {
if should_stop() {
return Ok(None);
}
match Arc::clone(&self.sql_bridge_reader_slots).try_acquire_owned() {
Ok(slot) => break slot,
Err(tokio::sync::TryAcquireError::Closed) => {
return Err(SqliteError::InvalidData(
"reader admission semaphore is closed".to_string(),
));
}
Err(tokio::sync::TryAcquireError::NoPermits) => {}
}
if started.elapsed() >= self.config.checkout_timeout {
self.reader_acquisition_counters.record_checkout_timeout();
return Err(pool_exhausted_error(
self.config.checkout_timeout,
self.max_readers,
));
}
match admission_attempt {
0..=7 => {
let spins = 1usize << admission_attempt;
for _ in 0..spins {
std::hint::spin_loop();
}
}
8..=15 => thread::yield_now(),
_ => {
let remaining = self
.config
.checkout_timeout
.saturating_sub(started.elapsed());
let sleep =
Duration::from_micros(50 * (1u64 << (admission_attempt - 16).min(6)));
thread::sleep(sleep.min(remaining).min(Duration::from_millis(2)));
}
}
admission_attempt = admission_attempt.saturating_add(1);
};
if self.max_readers == 0 {
self.ensure_pooled_writer_active()?;
loop {
if should_stop() {
return Ok(None);
}
let remaining = self
.config
.checkout_timeout
.saturating_sub(started.elapsed());
if remaining.is_zero() {
self.reader_acquisition_counters.record_checkout_timeout();
return Err(pool_exhausted_error(
self.config.checkout_timeout,
self.max_readers,
));
}
if let Some(guard) = self
.writer
.try_lock_for(remaining.min(Duration::from_millis(2)))
{
self.ensure_pooled_writer_active()?;
self.reader_acquisition_counters.record_pooled_checkout();
return Ok(Some(ReaderGuard {
lease: Some(ReaderLease::Shared(guard)),
admission_slot: Some(admission_slot),
pool: self,
reusable: Cell::new(true),
query_in_progress: Cell::new(false),
checked_out_at: Instant::now(),
dirty: Cell::new(false),
operation: None,
}));
}
}
}
let mut attempt = 0u32;
loop {
if should_stop() {
return Ok(None);
}
if let Some(conn) = self.readers.pop() {
self.reader_acquisition_counters.record_pooled_checkout();
return Ok(Some(ReaderGuard {
lease: Some(ReaderLease::Pooled(conn)),
admission_slot: Some(admission_slot),
pool: self,
reusable: Cell::new(true),
query_in_progress: Cell::new(false),
checked_out_at: Instant::now(),
dirty: Cell::new(false),
operation: None,
}));
}
if started.elapsed() >= self.config.checkout_timeout {
self.reader_acquisition_counters.record_checkout_timeout();
return Err(pool_exhausted_error(
self.config.checkout_timeout,
self.max_readers,
));
}
match attempt {
0..=7 => {
let spins = 1usize << attempt;
for _ in 0..spins {
std::hint::spin_loop();
}
}
8..=15 => thread::yield_now(),
_ => {
let remaining = self
.config
.checkout_timeout
.saturating_sub(started.elapsed());
let sleep = Duration::from_micros(50 * (1u64 << (attempt - 16).min(6)));
thread::sleep(sleep.min(remaining).min(Duration::from_millis(2)));
}
}
attempt = attempt.saturating_add(1);
}
}
pub fn writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
self.ensure_pooled_writer_active()?;
let Some(guard) = self.writer.try_lock_for(self.config.checkout_timeout) else {
self.writer_acquisition_counters
.pooled_timeouts
.fetch_add(1, Ordering::Relaxed);
let message = format!(
"timed out after {:?} waiting for sqlite writer connection",
self.config.checkout_timeout
);
crate::timeout_sink::emit_timeout(
&crate::timeout_sink::db_label(self),
crate::timeout_sink::Site::PoolAdmission,
&message,
Some(
self.config
.checkout_timeout
.as_millis()
.min(u128::from(u64::MAX)) as u64,
),
);
return Err(SqliteError::WriterPoolCheckoutTimeout {
timeout: self.config.checkout_timeout,
});
};
self.ensure_pooled_writer_active()?;
self.writer_acquisition_counters
.pooled_acquisitions
.fetch_add(1, Ordering::Relaxed);
Ok(WriterGuard {
guard,
origin: self.origin(),
})
}
pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
self.writer()
}
pub(crate) fn writer_until<C>(
&self,
should_stop: C,
) -> Result<Option<WriterGuard<'_>>, SqliteError>
where
C: Fn() -> bool,
{
self.ensure_pooled_writer_active()?;
let started = Instant::now();
loop {
if should_stop() {
return Ok(None);
}
let remaining = self
.config
.checkout_timeout
.saturating_sub(started.elapsed());
if let Some(guard) = self
.writer
.try_lock_for(remaining.min(Duration::from_millis(2)))
{
if should_stop() {
return Ok(None);
}
self.ensure_pooled_writer_active()?;
self.writer_acquisition_counters
.pooled_acquisitions
.fetch_add(1, Ordering::Relaxed);
return Ok(Some(WriterGuard {
guard,
origin: self.origin(),
}));
}
if started.elapsed() >= self.config.checkout_timeout {
self.writer_acquisition_counters
.pooled_timeouts
.fetch_add(1, Ordering::Relaxed);
let message = format!(
"timed out after {:?} waiting for sqlite writer connection",
self.config.checkout_timeout
);
crate::timeout_sink::emit_timeout(
&crate::timeout_sink::db_label(self),
crate::timeout_sink::Site::PoolAdmission,
&message,
Some(
self.config
.checkout_timeout
.as_millis()
.min(u128::from(u64::MAX)) as u64,
),
);
return Err(SqliteError::WriterPoolCheckoutTimeout {
timeout: self.config.checkout_timeout,
});
}
}
}
pub fn try_writer_nowait(&self) -> Result<WriterGuard<'_>, SqliteError> {
self.ensure_pooled_writer_active()?;
let guard = self.writer.try_lock().ok_or_else(|| {
SqliteError::InvalidData(
"writer connection busy (checkpoint skipped this tick)".to_string(),
)
})?;
self.ensure_pooled_writer_active()?;
Ok(WriterGuard {
guard,
origin: self.origin(),
})
}
pub(crate) fn retire_pooled_writer(&self, conn: &Connection) {
self.pooled_writer_retired.store(true, Ordering::Release);
if let Err(error) = conn.authorizer(Some(deny_retired_writer)) {
tracing::error!(
%error,
"failed to install the retired pooled-writer quarantine authorizer"
);
}
}
fn ensure_pooled_writer_active(&self) -> Result<(), SqliteError> {
if self.pooled_writer_retired.load(Ordering::Acquire) {
return Err(SqliteError::InvalidData(
"pooled writer connection retired after a terminal transaction fault".to_string(),
));
}
Ok(())
}
pub fn writer_acquisition_snapshot(&self) -> WriterAcquisitionSnapshot {
self.writer_acquisition_counters.snapshot()
}
pub fn reader_acquisition_snapshot(&self) -> ReaderAcquisitionSnapshot {
self.reader_acquisition_counters.snapshot(
self.max_readers.max(1),
self.sql_bridge_reader_slots.available_permits(),
)
}
pub(crate) fn record_reader_admission_timeout(&self) {
self.reader_acquisition_counters.record_checkout_timeout();
}
pub(crate) fn writer_acquisition_counters(&self) -> Arc<WriterAcquisitionCounters> {
Arc::clone(&self.writer_acquisition_counters)
}
pub fn available_readers(&self) -> usize {
self.readers.len()
}
pub fn max_readers(&self) -> usize {
self.max_readers
}
pub fn config(&self) -> &PoolConfig {
&self.config
}
pub fn main_pool_generation(&self) -> u64 {
*self.main_pool_generation.get_or_init(|| {
NEXT_MAIN_POOL_GENERATION
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| {
next.checked_add(1)
})
.expect("main pool generation exhausted")
})
}
pub(crate) fn reader_admission_timeout(&self, operation: &'static str) -> StorageError {
StorageError::AdmissionTimeout {
operation: operation.into(),
timeout_ms: u64::try_from(self.config.checkout_timeout.as_millis()).unwrap_or(u64::MAX),
pool_identity: Some(
self.identity_registration
.as_ref()
.map(PoolIdentityRegistration::label)
.unwrap_or_else(|| ":memory:".to_string()),
),
}
}
pub(crate) fn resolve_reader_checkout<'p>(
&self,
capability: StorageCapability,
operation: &'static str,
outcome: Result<Option<ReaderGuard<'p>>, SqliteError>,
) -> Result<ReaderGuard<'p>, StorageError> {
match outcome {
Ok(Some(mut guard)) => {
guard.label_operation(operation);
Ok(guard)
}
Ok(None) => Err(StorageError::Timeout {
operation: operation.into(),
}),
Err(error) => {
let is_pool_exhausted = matches!(
&error,
SqliteError::Rusqlite(rusqlite::Error::SqliteFailure(code, _))
if code.code == rusqlite::ErrorCode::DatabaseBusy
);
if is_pool_exhausted {
Err(self.reader_admission_timeout(operation))
} else {
Err(StorageError::driver(capability, operation, error))
}
}
}
}
pub(crate) fn sql_bridge_reader_slots(&self) -> Arc<Semaphore> {
Arc::clone(&self.sql_bridge_reader_slots)
}
pub(crate) fn sql_bridge_writer_slots(&self) -> Arc<Semaphore> {
Arc::clone(&self.sql_bridge_writer_slots)
}
pub fn origin(&self) -> TxOrigin {
self.origin.clone()
}
pub fn canonical_path(&self) -> Option<&Path> {
self.identity_path.as_deref()
}
pub fn write_queue_active(&self) -> bool {
debug_assert!(
self.config.write_queue_enabled.is_some(),
"write_queue_enabled must be resolved to Some(..) by ConnectionPool::new \
before any write_queue_active read"
);
self.config.write_queue_enabled.unwrap_or(false) && self.config.path.is_some()
}
pub fn writer_task_join_was_stored(&self) -> bool {
self.writer_task_join_stored.load(Ordering::SeqCst)
}
pub fn writer_task_handle(&self) -> Result<Option<WriterTaskHandle>, StorageError> {
debug_assert!(
self.config.write_queue_enabled.is_some(),
"write_queue_enabled must be resolved to Some(..) by ConnectionPool::new \
before any writer_task_handle read"
);
if !self.config.write_queue_enabled.unwrap_or(false) {
return Ok(None);
}
if let Some(existing) = self.writer_task.get() {
return Ok(existing.clone());
}
if tokio::runtime::Handle::try_current().is_err() {
return Err(StorageError::WriterTaskNoRuntime);
}
Ok(self
.writer_task
.get_or_init(|| {
#[cfg(test)]
self.writer_task_spawn_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
match crate::writer_task::spawn(self, self.config.write_queue_capacity) {
Ok(handle) => Some(handle),
Err(e) => {
tracing::warn!(
error = %e,
"KHIVE_WRITE_QUEUE=1 but the writer task failed to spawn; \
writes fall back to the pool-mutex path"
);
None
}
}
})
.clone())
}
pub(crate) fn writer_task_for_write(
&self,
cached: Option<&WriterTaskHandle>,
operation: &'static str,
) -> Result<Option<WriterTaskHandle>, StorageError> {
let handle = match cached {
Some(handle) => Some(handle.clone()),
None => match self.writer_task_handle() {
Ok(handle) => handle,
Err(error) if self.config.write_routing_strict => return Err(error),
Err(_) => None,
},
};
if handle.is_none() && self.config.write_routing_strict {
return Err(StorageError::Pool {
operation: operation.into(),
message: "strict write routing requires a writer-task handle; no handle is \
available, so the direct writer fallback was refused"
.into(),
});
}
Ok(handle)
}
pub(crate) fn record_direct_route(&self, site: crate::timeout_sink::Site) {
if self.write_queue_active() {
crate::timeout_sink::emit_direct_route_violation(
&crate::timeout_sink::db_label(self),
site,
);
}
}
pub fn writer_task_for_runtime_write(
&self,
operation: RuntimeWriteOperation,
) -> Result<Option<WriterTaskHandle>, StorageError> {
let handle = self.writer_task_for_write(None, operation.operation())?;
if handle.is_none() {
self.record_direct_route(operation.fallback_site());
}
Ok(handle)
}
#[cfg(test)]
pub(crate) fn writer_task_spawn_count(&self) -> usize {
self.writer_task_spawn_count
.load(std::sync::atomic::Ordering::SeqCst)
}
pub(crate) fn set_writer_task_join(&self, join: tokio::task::JoinHandle<()>) {
let first_store = !self.writer_task_join_stored.swap(true, Ordering::SeqCst);
debug_assert!(
first_store,
"writer task JoinHandle stored twice (even counting a taken one); \
the writer_task OnceLock is supposed to make spawn at-most-once per pool"
);
if first_store {
*self.writer_task_join.lock() = Some(join);
}
}
pub fn take_writer_task_join(&self) -> Option<tokio::task::JoinHandle<()>> {
self.writer_task_join.lock().take()
}
pub fn legacy_conn(&self) -> Arc<Mutex<Connection>> {
Arc::clone(&self.writer)
}
fn open_reader_connection(&self) -> Result<Connection, SqliteError> {
let path = self.read_connection_path()?;
open_reader_connection(path, &self.config)
}
fn read_connection_path(&self) -> Result<&Path, SqliteError> {
self.read_only_open_target
.as_deref()
.or(self.config.path.as_deref())
.ok_or_else(|| {
SqliteError::InvalidData(
"in-memory databases do not support standalone connections".to_string(),
)
})
}
pub fn open_standalone_writer(&self) -> Result<Connection, SqliteError> {
let conn = self.open_standalone_writer_untracked()?;
self.writer_acquisition_counters
.standalone_acquisitions
.fetch_add(1, Ordering::Relaxed);
Ok(conn)
}
pub(crate) fn open_standalone_writer_untracked(&self) -> Result<Connection, SqliteError> {
let path = self.config.path.as_ref().ok_or_else(|| {
SqliteError::InvalidData(
"in-memory databases do not support standalone connections".to_string(),
)
})?;
if self.config.read_only {
return Err(SqliteError::InvalidData(
"database is read-only: standalone write connections are not permitted".to_string(),
));
}
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_NO_MUTEX
| OpenFlags::SQLITE_OPEN_URI,
)?;
register_writer_clock(&conn)?;
conn.busy_timeout(self.config.busy_timeout)?;
self.checkpoint_ownership
.configure_wal_autocheckpoint(&conn)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
let wal_enabled =
self.config.wal_mode && current_journal_mode(&conn)?.eq_ignore_ascii_case("wal");
if wal_enabled {
conn.pragma_update(
None,
"journal_size_limit",
self.config.journal_size_limit_bytes,
)?;
}
Ok(conn)
}
#[cfg(test)]
pub(crate) fn effective_wal_autocheckpoint_pages(&self) -> u32 {
self.checkpoint_ownership.wal_autocheckpoint_pages()
}
pub fn claim_checkpoint_ownership(&self) -> Result<(), SqliteError> {
if !self.checkpoint_ownership.begin_claim() {
return Ok(());
}
let result = (|| {
if !self.config.read_only {
let writer = self.writer()?;
writer.conn().pragma_update(None, "wal_autocheckpoint", 0)?;
}
Ok(())
})();
self.checkpoint_ownership.finish_claim(result.is_ok());
result
}
pub async fn propagate_checkpoint_claim_to_writer_task(&self) -> Result<(), StorageError> {
let Some(handle) = self.writer_task_handle()? else {
return Ok(());
};
handle
.send_top_level(|conn| {
conn.pragma_update(None, "wal_autocheckpoint", 0)
.map_err(|e| StorageError::Pool {
operation: "claim_checkpoint_ownership".into(),
message: e.to_string(),
})
})
.await
}
pub(crate) fn open_standalone_reader(
&self,
purpose: StandaloneReaderPurpose,
) -> Result<Connection, SqliteError> {
let path = self.read_connection_path()?;
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_NO_MUTEX
| OpenFlags::SQLITE_OPEN_URI,
)?;
configure_reader_connection(&conn, &self.config)?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
self.reader_acquisition_counters
.record_standalone_open(purpose);
Ok(conn)
}
fn return_reader(&self, conn: Connection, dirty: bool) {
if self.max_readers == 0 {
return;
}
if reset_reader_connection(&conn, dirty, &self.config)
&& reader_connection_is_healthy(&conn)
{
self.enqueue_reader_slot(conn);
return;
}
close_connection_quietly(conn);
self.replace_discarded_reader_slot();
}
fn enqueue_reader_slot(&self, conn: Connection) {
if let Err(conn) = self.readers.push(conn) {
eprintln!("[sqlite-pool] reader pool queue full, discarding replacement connection");
close_connection_quietly(conn);
}
}
fn replace_discarded_reader_slot(&self) {
match self.open_reader_connection() {
Ok(conn) => self.enqueue_reader_slot(conn),
Err(error) => {
self.reader_acquisition_counters
.record_reader_replacement_open_failure();
tracing::warn!(
%error,
"sqlite-pool: reader replacement connection failed to open; the physical \
pool permanently shrinks by one slot below max_readers"
);
}
}
}
}
const MAX_SYMLINK_DEPTH: u32 = 40;
fn mint_db_identity(configured_path: &Path) -> Result<(DbIdentity, PathBuf), SqliteError> {
let absolute = if configured_path.is_absolute() {
configured_path.to_path_buf()
} else {
let cwd = std::env::current_dir().map_err(|e| {
SqliteError::InvalidData(format!(
"cannot mint database identity for {configured_path:?}: failed to resolve the \
process current directory: {e}"
))
})?;
cwd.join(configured_path)
};
if absolute.exists() {
let canonical = absolute.canonicalize().map_err(|e| {
SqliteError::InvalidData(format!(
"cannot mint database identity: failed to canonicalize existing path \
{absolute:?}: {e}"
))
})?;
return Ok((
DbIdentity::new(canonical.clone().into_os_string()),
canonical,
));
}
let resolved_target = resolve_symlink_chain(&absolute)?;
let parent = resolved_target.parent().ok_or_else(|| {
SqliteError::InvalidData(format!(
"cannot mint database identity for {resolved_target:?}: path has no parent \
directory"
))
})?;
let file_name = resolved_target.file_name().ok_or_else(|| {
SqliteError::InvalidData(format!(
"cannot mint database identity for {resolved_target:?}: path has no file name"
))
})?;
let canonical_parent = parent.canonicalize().map_err(|e| {
SqliteError::InvalidData(format!(
"cannot mint database identity: parent directory {parent:?} of first-open path \
{resolved_target:?} does not exist or is inaccessible: {e}"
))
})?;
let mut identity_path = canonical_parent;
identity_path.push(file_name);
Ok((
DbIdentity::new(identity_path.clone().into_os_string()),
identity_path,
))
}
fn resolve_symlink_chain(path: &Path) -> Result<PathBuf, SqliteError> {
let mut current = path.to_path_buf();
for _ in 0..MAX_SYMLINK_DEPTH {
match fs::symlink_metadata(¤t) {
Ok(meta) if meta.file_type().is_symlink() => {
let target = fs::read_link(¤t).map_err(|e| {
SqliteError::InvalidData(format!(
"cannot mint database identity: failed to read symlink {current:?}: {e}"
))
})?;
current = if target.is_absolute() {
target
} else {
match current.parent() {
Some(parent) => parent.join(&target),
None => target,
}
};
}
_ => return Ok(current),
}
}
Err(SqliteError::InvalidData(format!(
"cannot mint database identity for {path:?}: symlink chain exceeds \
{MAX_SYMLINK_DEPTH} levels"
)))
}
fn effective_reader_count(config: &PoolConfig, wal_enabled: bool) -> usize {
if config.path.is_some() && config.read_only {
config.max_readers.max(1)
} else if config.path.is_some() && config.wal_mode && wal_enabled {
config.max_readers
} else {
0
}
}
fn open_writer_connection(
config: &PoolConfig,
read_only_open_target: Option<&Path>,
) -> Result<Connection, SqliteError> {
match config.path.as_ref() {
Some(path) => {
let flags = if config.read_only {
writer_read_only_open_flags()
} else {
writer_open_flags()
};
let target = if config.read_only {
read_only_open_target.ok_or_else(|| {
SqliteError::InvalidData(
"file-backed read-only pool has no canonical open target".to_string(),
)
})?
} else {
path
};
Connection::open_with_flags(target, flags).map_err(Into::into)
}
None => Connection::open_in_memory().map_err(Into::into),
}
}
fn read_only_open_target(
config: &PoolConfig,
physical_path: Option<&Path>,
) -> Result<Option<PathBuf>, SqliteError> {
if !config.read_only {
return Ok(None);
}
let Some(path) = physical_path else {
return Ok(None);
};
read_only_wal_open_target_for_path(path).map(Some)
}
fn read_only_wal_open_target_for_path(path: &Path) -> Result<PathBuf, SqliteError> {
if !sqlite_header_uses_wal(path)? {
return Ok(path.to_path_buf());
}
let shm = sqlite_sidecar_path(path, "-shm");
match fs::metadata(&shm) {
Ok(metadata) if metadata.permissions().readonly() => {
let wal = sqlite_sidecar_path(path, "-wal");
match fs::metadata(&wal) {
Ok(_) => Ok(path.to_path_buf()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err(SqliteError::InvalidData(format!(
"read-only WAL snapshot {} has a shared-memory sidecar {} but no WAL \
sidecar {}; refusing the inconsistent sidecar set before SQLite open",
path.display(),
shm.display(),
wal.display(),
)))
}
Err(error) => Err(SqliteError::Io(error)),
}
}
Ok(_) => Err(SqliteError::InvalidData(format!(
"read-only WAL snapshot {} has a writable WAL shared-memory sidecar {}; close every \
live writer and remove the transient -shm file (or make a genuinely frozen snapshot) \
before inspection",
path.display(),
shm.display(),
))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let wal = sqlite_sidecar_path(path, "-wal");
match fs::metadata(&wal) {
Ok(metadata) if metadata.len() > 0 => Err(SqliteError::InvalidData(format!(
"read-only WAL snapshot {} has a non-empty WAL sidecar {} but no read-only \
shared-memory sidecar {}; refusing before SQLite open because immutable \
mode would omit committed WAL frames and ordinary read-only mode would \
create or mutate -shm; include the frozen read-only -shm beside this \
snapshot, or checkpoint a writable copy before inspection",
path.display(),
wal.display(),
shm.display(),
))),
Ok(_) => sqlite_immutable_uri(path),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
sqlite_immutable_uri(path)
}
Err(error) => Err(SqliteError::Io(error)),
}
}
Err(error) => Err(SqliteError::Io(error)),
}
}
pub(crate) fn open_read_only_snapshot_connection(path: &Path) -> Result<Connection, SqliteError> {
let (_, physical_path) = mint_db_identity(path)?;
let target = read_only_wal_open_target_for_path(&physical_path)?;
Connection::open_with_flags(&target, reader_open_flags()).map_err(Into::into)
}
fn sqlite_header_uses_wal(path: &Path) -> Result<bool, SqliteError> {
let mut file = fs::File::open(path)?;
let mut header = [0_u8; 20];
if let Err(error) = file.read_exact(&mut header) {
if error.kind() == std::io::ErrorKind::UnexpectedEof {
return Ok(false);
}
return Err(SqliteError::Io(error));
}
Ok(&header[..16] == b"SQLite format 3\0" && header[18] == 2 && header[19] == 2)
}
fn sqlite_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
let mut sidecar = path.as_os_str().to_os_string();
sidecar.push(suffix);
PathBuf::from(sidecar)
}
fn sqlite_immutable_uri(path: &Path) -> Result<PathBuf, SqliteError> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let mut uri = String::from("file:");
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
push_sqlite_uri_path(&mut uri, absolute.as_os_str().as_bytes());
}
#[cfg(not(unix))]
{
let path = absolute.to_str().ok_or_else(|| {
SqliteError::InvalidData(format!(
"read-only WAL snapshot path is not representable as a SQLite URI: {}",
absolute.display()
))
})?;
let normalized = path.replace('\\', "/");
if cfg!(windows) && !normalized.starts_with('/') {
uri.push('/');
}
push_sqlite_uri_path(&mut uri, normalized.as_bytes());
}
uri.push_str("?mode=ro&immutable=1");
Ok(PathBuf::from(uri))
}
fn push_sqlite_uri_path(uri: &mut String, bytes: &[u8]) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for &byte in bytes {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
uri.push(byte as char);
} else {
uri.push('%');
uri.push(HEX[(byte >> 4) as usize] as char);
uri.push(HEX[(byte & 0x0f) as usize] as char);
}
}
}
fn open_reader_connection(path: &Path, config: &PoolConfig) -> Result<Connection, SqliteError> {
let conn = Connection::open_with_flags(path, reader_open_flags())?;
configure_reader_connection(&conn, config)?;
Ok(conn)
}
fn writer_open_flags() -> OpenFlags {
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX
}
fn writer_read_only_open_flags() -> OpenFlags {
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
}
fn reader_open_flags() -> OpenFlags {
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
}
fn register_writer_clock(conn: &Connection) -> Result<(), SqliteError> {
conn.create_scalar_function(
"khive_now_micros",
0,
rusqlite::functions::FunctionFlags::SQLITE_UTF8,
|_| Ok(chrono::Utc::now().timestamp_micros()),
)?;
Ok(())
}
pub(crate) fn rfc3339_instant_key(instant: chrono::DateTime<chrono::Utc>) -> Vec<u8> {
let mut key = Vec::with_capacity(12);
key.extend_from_slice(&((instant.timestamp() as u64) ^ (1_u64 << 63)).to_be_bytes());
key.extend_from_slice(&instant.timestamp_subsec_nanos().to_be_bytes());
key
}
fn register_rfc3339_key(conn: &Connection) -> Result<(), SqliteError> {
use rusqlite::functions::FunctionFlags;
use rusqlite::types::ValueRef;
conn.create_scalar_function(
"khive_rfc3339_key",
1,
FunctionFlags::SQLITE_UTF8
| FunctionFlags::SQLITE_DETERMINISTIC
| FunctionFlags::SQLITE_INNOCUOUS,
|ctx| {
let text = match ctx.get_raw(0) {
ValueRef::Text(bytes) => std::str::from_utf8(bytes).ok(),
_ => None,
};
let key = text
.and_then(|text| text.parse::<chrono::DateTime<chrono::Utc>>().ok())
.map(rfc3339_instant_key);
Ok(key)
},
)?;
Ok(())
}
fn configure_writer_connection(
conn: &Connection,
config: &PoolConfig,
) -> Result<bool, SqliteError> {
register_writer_clock(conn)?;
register_rfc3339_key(conn)?;
if config.read_only {
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(config.busy_timeout)?;
conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "query_only", "ON")?;
let wal_enabled =
config.wal_mode && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
return Ok(wal_enabled);
}
let wants_wal = config.path.is_some() && config.wal_mode;
if wants_wal {
conn.pragma_update(None, "journal_mode", "WAL")?;
}
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(config.busy_timeout)?;
conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(
None,
"wal_autocheckpoint",
FALLBACK_WAL_AUTOCHECKPOINT_PAGES,
)?;
let wal_enabled = wants_wal && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
if wal_enabled {
conn.pragma_update(None, "journal_size_limit", config.journal_size_limit_bytes)?;
}
Ok(wal_enabled)
}
fn configure_reader_connection(conn: &Connection, config: &PoolConfig) -> Result<(), SqliteError> {
register_rfc3339_key(conn)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(config.busy_timeout)?;
conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
Ok(())
}
fn current_journal_mode(conn: &Connection) -> Result<String, SqliteError> {
conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))
.map(|mode| mode.to_ascii_lowercase())
.map_err(Into::into)
}
fn reset_reader_connection(conn: &Connection, dirty: bool, config: &PoolConfig) -> bool {
if !conn.is_autocommit() {
match conn.execute_batch("ROLLBACK") {
Ok(()) => {}
Err(rusqlite::Error::SqliteFailure(err, _)) => {
if matches!(
err.code,
rusqlite::ErrorCode::CannotOpen
| rusqlite::ErrorCode::DatabaseCorrupt
| rusqlite::ErrorCode::NotADatabase
| rusqlite::ErrorCode::DiskFull
) {
return false;
}
}
Err(_) => return false,
}
if !conn.is_autocommit() {
return false;
}
}
if !dirty {
return true;
}
reader_connection_state_is_pristine(conn)
&& reader_connection_settings_match_baseline(conn, config, 0)
}
fn reader_connection_state_is_pristine(conn: &Connection) -> bool {
let has_temp_objects: bool = match conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_temp_master)",
[],
|row| row.get(0),
) {
Ok(v) => v,
Err(_) => return false,
};
if has_temp_objects {
return false;
}
let attached_databases: i64 = match conn.query_row(
"SELECT COUNT(*) FROM pragma_database_list WHERE name NOT IN ('main', 'temp')",
[],
|row| row.get(0),
) {
Ok(v) => v,
Err(_) => return false,
};
attached_databases == 0
}
fn reader_connection_settings_match_baseline(
conn: &Connection,
config: &PoolConfig,
expected_query_only: i64,
) -> bool {
let expected_busy_timeout_ms =
i64::try_from(config.busy_timeout.as_millis()).unwrap_or(i64::MAX);
let expected_cache_size: i64 = CACHE_SIZE_KIB.parse().unwrap_or(-65536);
let checks: [(&str, i64); 8] = [
("query_only", expected_query_only),
("writable_schema", 0),
("foreign_keys", 1),
("busy_timeout", expected_busy_timeout_ms),
("cache_size", expected_cache_size),
("temp_store", 2),
("read_uncommitted", 0),
("defer_foreign_keys", 0),
];
checks.iter().all(|(pragma, expected)| {
conn.pragma_query_value(None, pragma, |row| row.get::<_, i64>(0))
.map(|actual| actual == *expected)
.unwrap_or(false)
})
}
fn restore_shared_reader_state(conn: &Connection, config: &PoolConfig) -> bool {
if !detach_non_main_databases(conn) {
return false;
}
if !drop_temp_objects(conn) {
return false;
}
let expected_query_only = i64::from(config.read_only);
if reset_observable_settings(conn, config, expected_query_only).is_err() {
return false;
}
reader_connection_state_is_pristine(conn)
&& reader_connection_settings_match_baseline(conn, config, expected_query_only)
}
fn detach_non_main_databases(conn: &Connection) -> bool {
loop {
let name: Option<String> = match conn.query_row(
"SELECT name FROM pragma_database_list WHERE name NOT IN ('main', 'temp') LIMIT 1",
[],
|row| row.get(0),
) {
Ok(name) => Some(name),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(_) => return false,
};
let Some(name) = name else {
return true;
};
let quoted = format!("\"{}\"", name.replace('"', "\"\""));
if conn
.execute_batch(&format!("DETACH DATABASE {quoted}"))
.is_err()
{
return false;
}
}
}
fn drop_temp_objects(conn: &Connection) -> bool {
for (kind, ddl_keyword) in [
("view", "VIEW"),
("trigger", "TRIGGER"),
("index", "INDEX"),
("table", "TABLE"),
] {
loop {
let name: Option<String> = match conn.query_row(
"SELECT name FROM sqlite_temp_master WHERE type = ?1 LIMIT 1",
[kind],
|row| row.get(0),
) {
Ok(name) => Some(name),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(_) => return false,
};
let Some(name) = name else {
break;
};
let quoted = format!("\"{}\"", name.replace('"', "\"\""));
if conn
.execute_batch(&format!("DROP {ddl_keyword} IF EXISTS temp.{quoted}"))
.is_err()
{
return false;
}
}
}
true
}
fn reset_observable_settings(
conn: &Connection,
config: &PoolConfig,
expected_query_only: i64,
) -> Result<(), rusqlite::Error> {
conn.pragma_update(None, "query_only", expected_query_only)?;
conn.pragma_update(None, "writable_schema", 0)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(config.busy_timeout)?;
conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "read_uncommitted", 0)?;
conn.pragma_update(None, "defer_foreign_keys", 0)?;
Ok(())
}
fn reader_connection_is_healthy(conn: &Connection) -> bool {
match conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) {
Ok(_) => true,
Err(rusqlite::Error::SqliteFailure(err, _)) => !matches!(
err.code,
rusqlite::ErrorCode::CannotOpen
| rusqlite::ErrorCode::NotADatabase
| rusqlite::ErrorCode::DatabaseCorrupt
| rusqlite::ErrorCode::PermissionDenied
| rusqlite::ErrorCode::SystemIoFailure
),
Err(_) => true,
}
}
fn close_connection_quietly(conn: Connection) {
match conn.close() {
Ok(()) => {}
Err((conn, _)) => drop(conn),
}
}
fn pool_exhausted_error(timeout: Duration, max_readers: usize) -> SqliteError {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
Some(format!(
"Pool exhausted: no reader available after {timeout:?} (max_readers={max_readers})"
)),
)
.into()
}
#[cfg(test)]
#[path = "runtime_write_routing_tests.rs"]
mod runtime_write_routing_tests;
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn constructor_writer_cancels_after_entering_the_wait_without_pool_timeout() {
let pool = ConnectionPool::new(PoolConfig {
path: None,
..PoolConfig::default()
})
.unwrap();
let held = pool.writer().unwrap();
let before = pool.writer_acquisition_snapshot();
let checks = Cell::new(0);
let stopped = pool
.writer_until(|| {
checks.set(checks.get() + 1);
checks.get() == 2
})
.unwrap();
assert!(
stopped.is_none(),
"second predicate check must stop an in-flight wait"
);
assert_eq!(checks.get(), 2);
assert_eq!(pool.writer_acquisition_snapshot(), before);
drop(held);
}
#[tokio::test]
async fn constructor_writer_observes_absolute_blocking_deadline() {
let pool = ConnectionPool::new(PoolConfig {
path: None,
checkout_timeout: Duration::from_secs(5),
..PoolConfig::default()
})
.unwrap();
let held = pool.writer().unwrap();
let context =
khive_storage::scope_request_read_deadline(Duration::from_millis(20), async {
khive_storage::capture_request_read_context()
})
.await;
let before = pool.writer_acquisition_snapshot();
let started = Instant::now();
let stopped = pool
.writer_until(|| context.blocking_stop_reason().is_some())
.unwrap();
assert!(stopped.is_none());
assert!(
started.elapsed() < Duration::from_secs(1),
"request deadline must beat pool timeout"
);
assert_eq!(pool.writer_acquisition_snapshot(), before);
drop(held);
}
#[test]
fn constructor_writer_preserves_uncancelled_checkout_timeout() {
let pool = ConnectionPool::new(PoolConfig {
path: None,
checkout_timeout: Duration::from_millis(5),
..PoolConfig::default()
})
.unwrap();
let held = pool.writer().unwrap();
let before = pool.writer_acquisition_snapshot();
let result = pool.writer_until(|| false);
assert!(
matches!(result, Err(SqliteError::WriterPoolCheckoutTimeout { timeout }) if timeout == Duration::from_millis(5))
);
let after = pool.writer_acquisition_snapshot();
assert_eq!(after.timeouts, before.timeouts + 1);
assert_eq!(after.pooled_acquisitions, before.pooled_acquisitions);
drop(held);
}
struct WarningCapture {
messages: Arc<std::sync::Mutex<Vec<String>>>,
}
impl tracing::Subscriber for WarningCapture {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
struct Visitor(Option<String>);
impl tracing::field::Visit for Visitor {
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
if field.name() == "message" {
self.0 = Some(format!("{value:?}"));
}
}
}
let mut visitor = Visitor(None);
event.record(&mut visitor);
if let Some(message) = visitor.0 {
self.messages.lock().unwrap().push(message);
}
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
struct CwdGuard {
original: PathBuf,
}
impl CwdGuard {
fn enter(dir: &Path) -> Self {
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(dir).unwrap();
Self { original }
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original);
}
}
const POOL_ENV_VARS: [&str; 7] = [
"KHIVE_BUSY_TIMEOUT_SECS",
"KHIVE_CHECKOUT_TIMEOUT_SECS",
"KHIVE_WAL_AUTOCHECKPOINT_PAGES",
"KHIVE_JOURNAL_SIZE_LIMIT_BYTES",
"KHIVE_WRITE_QUEUE",
"KHIVE_WRITE_QUEUE_CAPACITY",
"KHIVE_WRITE_ROUTING",
];
struct PoolEnvGuard {
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl PoolEnvGuard {
fn capture() -> Self {
Self {
saved: POOL_ENV_VARS
.into_iter()
.map(|key| (key, std::env::var_os(key)))
.collect(),
}
}
}
impl Drop for PoolEnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
fn clear_pool_env() -> PoolEnvGuard {
let guard = PoolEnvGuard::capture();
for var in POOL_ENV_VARS {
std::env::remove_var(var);
}
guard
}
fn wal_autocheckpoint_pages(conn: &Connection) -> u32 {
conn.pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
.expect("read PRAGMA wal_autocheckpoint")
}
fn journal_size_limit_bytes(conn: &Connection) -> i64 {
conn.pragma_query_value(None, "journal_size_limit", |row| row.get(0))
.expect("read PRAGMA journal_size_limit")
}
#[test]
fn read_only_rollback_journal_pool_keeps_a_dedicated_reader() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("read_only_delete_journal.db");
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE snapshot_row(id INTEGER PRIMARY KEY);")
.unwrap();
let mode: String = conn
.pragma_query_value(None, "journal_mode", |row| row.get(0))
.unwrap();
assert_eq!(mode.to_ascii_lowercase(), "delete");
}
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.unwrap();
assert!(
pool.max_readers() > 0,
"a read-only rollback-journal snapshot must use a genuine read-only reader, not \
alias reader() onto the query-only writer slot"
);
let reader = pool.reader().expect("dedicated read-only reader checkout");
let count: i64 = reader
.conn()
.query_row("SELECT COUNT(*) FROM snapshot_row", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
drop(reader);
assert_eq!(
pool.writer_acquisition_snapshot(),
WriterAcquisitionSnapshot::default(),
"constructing and reading a rollback-journal snapshot must never acquire the writer"
);
}
fn sqlite_sidecar(path: &Path, suffix: &str) -> PathBuf {
let mut sidecar = path.as_os_str().to_os_string();
sidecar.push(suffix);
PathBuf::from(sidecar)
}
fn directory_entries(path: &Path) -> Vec<std::ffi::OsString> {
let mut entries = std::fs::read_dir(path)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
entries.sort();
entries
}
#[test]
fn read_only_persistent_wal_without_shm_is_refused_without_mutation() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("wal-source.db");
let snapshot = dir.path().join("snapshot ?#%.db");
let source_wal = sqlite_sidecar(&source, "-wal");
let snapshot_wal = sqlite_sidecar(&snapshot, "-wal");
let snapshot_shm = sqlite_sidecar(&snapshot, "-shm");
let source_conn = Connection::open(&source).unwrap();
let mode: String = source_conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.unwrap();
assert_eq!(mode.to_ascii_lowercase(), "wal");
source_conn
.pragma_update(None, "wal_autocheckpoint", 0)
.unwrap();
source_conn
.execute_batch(
"CREATE TABLE snapshot_row(id INTEGER PRIMARY KEY, body TEXT NOT NULL);\
INSERT INTO snapshot_row(body) VALUES ('committed-only-in-wal');",
)
.unwrap();
assert!(source_wal.exists(), "fixture must retain a WAL sidecar");
std::fs::copy(&source, &snapshot).unwrap();
std::fs::copy(&source_wal, &snapshot_wal).unwrap();
assert!(
!snapshot_shm.exists(),
"fixture intentionally omits the transient shared-memory index"
);
let main_before = std::fs::read(&snapshot).unwrap();
let wal_before = std::fs::read(&snapshot_wal).unwrap();
let entries_before = directory_entries(dir.path());
let error = match ConnectionPool::new(PoolConfig {
path: Some(snapshot.clone()),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
}) {
Ok(_) => panic!("a non-empty WAL without its frozen -shm must fail closed"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("would omit committed WAL frames"),
"diagnostic must explain why neither unsafe open mode is allowed: {error}"
);
assert_eq!(std::fs::read(&snapshot).unwrap(), main_before);
assert_eq!(std::fs::read(&snapshot_wal).unwrap(), wal_before);
assert_eq!(directory_entries(dir.path()), entries_before);
assert!(
!snapshot_shm.exists(),
"read-only admission and every reader must keep the source free of -shm"
);
drop(source_conn);
}
#[test]
fn read_only_persistent_wal_with_read_only_shm_reads_without_mutation() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("wal-source.db");
let snapshot = dir.path().join("frozen-wal-snapshot.db");
let source_wal = sqlite_sidecar(&source, "-wal");
let source_shm = sqlite_sidecar(&source, "-shm");
let snapshot_wal = sqlite_sidecar(&snapshot, "-wal");
let snapshot_shm = sqlite_sidecar(&snapshot, "-shm");
let source_conn = Connection::open(&source).unwrap();
let mode: String = source_conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.unwrap();
assert_eq!(mode.to_ascii_lowercase(), "wal");
source_conn
.pragma_update(None, "wal_autocheckpoint", 0)
.unwrap();
source_conn
.execute_batch(
"CREATE TABLE snapshot_row(id INTEGER PRIMARY KEY, body TEXT NOT NULL);\
INSERT INTO snapshot_row(body) VALUES ('committed-only-in-wal');",
)
.unwrap();
assert!(source_wal.exists() && source_shm.exists());
std::fs::copy(&source, &snapshot).unwrap();
std::fs::copy(&source_wal, &snapshot_wal).unwrap();
std::fs::copy(&source_shm, &snapshot_shm).unwrap();
let snapshot_paths = [&snapshot, &snapshot_wal, &snapshot_shm];
let original_permissions =
snapshot_paths.map(|path| std::fs::metadata(path).unwrap().permissions());
for path in snapshot_paths {
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_readonly(true);
std::fs::set_permissions(path, permissions).unwrap();
}
let main_before = std::fs::read(&snapshot).unwrap();
let wal_before = std::fs::read(&snapshot_wal).unwrap();
let shm_before = std::fs::read(&snapshot_shm).unwrap();
let entries_before = directory_entries(dir.path());
let pool = ConnectionPool::new(PoolConfig {
path: Some(snapshot.clone()),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.unwrap();
let reader = pool.reader().unwrap();
let body: String = reader
.conn()
.query_row("SELECT body FROM snapshot_row", [], |row| row.get(0))
.unwrap();
assert_eq!(body, "committed-only-in-wal");
drop(reader);
let standalone = pool
.open_standalone_reader(StandaloneReaderPurpose::DiagnosticsIndependentSnapshot)
.unwrap();
let count: i64 = standalone
.query_row("SELECT COUNT(*) FROM snapshot_row", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
drop(standalone);
drop(pool);
assert_eq!(std::fs::read(&snapshot).unwrap(), main_before);
assert_eq!(std::fs::read(&snapshot_wal).unwrap(), wal_before);
assert_eq!(std::fs::read(&snapshot_shm).unwrap(), shm_before);
assert_eq!(directory_entries(dir.path()), entries_before);
for (path, permissions) in snapshot_paths.into_iter().zip(original_permissions) {
std::fs::set_permissions(path, permissions).unwrap();
}
drop(source_conn);
}
#[cfg(unix)]
#[test]
fn read_only_frozen_wal_symlink_reads_target_frames_without_mutation() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("wal-source.db");
let snapshot = dir.path().join("frozen-target.db");
let alias = dir.path().join("frozen-alias.db");
let source_wal = sqlite_sidecar(&source, "-wal");
let source_shm = sqlite_sidecar(&source, "-shm");
let snapshot_wal = sqlite_sidecar(&snapshot, "-wal");
let snapshot_shm = sqlite_sidecar(&snapshot, "-shm");
let alias_wal = sqlite_sidecar(&alias, "-wal");
let alias_shm = sqlite_sidecar(&alias, "-shm");
let source_conn = Connection::open(&source).unwrap();
let mode: String = source_conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.unwrap();
assert_eq!(mode.to_ascii_lowercase(), "wal");
source_conn
.pragma_update(None, "wal_autocheckpoint", 0)
.unwrap();
source_conn
.execute_batch(
"CREATE TABLE snapshot_row(id INTEGER PRIMARY KEY, body TEXT NOT NULL);\
INSERT INTO snapshot_row(body) VALUES ('visible-through-target-wal');",
)
.unwrap();
assert!(source_wal.exists() && source_shm.exists());
std::fs::copy(&source, &snapshot).unwrap();
std::fs::copy(&source_wal, &snapshot_wal).unwrap();
std::fs::copy(&source_shm, &snapshot_shm).unwrap();
symlink(&snapshot, &alias).unwrap();
assert!(!alias_wal.exists() && !alias_shm.exists());
let snapshot_paths = [&snapshot, &snapshot_wal, &snapshot_shm];
let original_permissions =
snapshot_paths.map(|path| std::fs::metadata(path).unwrap().permissions());
for path in snapshot_paths {
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_readonly(true);
std::fs::set_permissions(path, permissions).unwrap();
}
let main_before = std::fs::read(&snapshot).unwrap();
let wal_before = std::fs::read(&snapshot_wal).unwrap();
let shm_before = std::fs::read(&snapshot_shm).unwrap();
let entries_before = directory_entries(dir.path());
let pool = ConnectionPool::new(PoolConfig {
path: Some(alias.clone()),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.unwrap();
let reader = pool.reader().unwrap();
let body: String = reader
.conn()
.query_row("SELECT body FROM snapshot_row", [], |row| row.get(0))
.unwrap();
assert_eq!(body, "visible-through-target-wal");
drop(reader);
let standalone = pool
.open_standalone_reader(StandaloneReaderPurpose::DiagnosticsIndependentSnapshot)
.unwrap();
let count: i64 = standalone
.query_row("SELECT COUNT(*) FROM snapshot_row", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
drop(standalone);
drop(pool);
assert_eq!(std::fs::read(&snapshot).unwrap(), main_before);
assert_eq!(std::fs::read(&snapshot_wal).unwrap(), wal_before);
assert_eq!(std::fs::read(&snapshot_shm).unwrap(), shm_before);
assert_eq!(directory_entries(dir.path()), entries_before);
assert!(!alias_wal.exists() && !alias_shm.exists());
for (path, permissions) in snapshot_paths.into_iter().zip(original_permissions) {
std::fs::set_permissions(path, permissions).unwrap();
}
drop(source_conn);
}
#[test]
fn read_only_clean_wal_snapshot_is_sidecar_free() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("clean snapshot ?#%.db");
{
let conn = Connection::open(&path).unwrap();
let mode: String = conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.unwrap();
assert_eq!(mode.to_ascii_lowercase(), "wal");
conn.execute_batch(
"CREATE TABLE snapshot_row(id INTEGER PRIMARY KEY, body TEXT NOT NULL);\
INSERT INTO snapshot_row(body) VALUES ('checkpointed');",
)
.unwrap();
}
let wal = sqlite_sidecar(&path, "-wal");
let shm = sqlite_sidecar(&path, "-shm");
assert!(!wal.exists() && !shm.exists());
assert!(sqlite_header_uses_wal(&path).unwrap());
let original_permissions = std::fs::metadata(&path).unwrap().permissions();
let mut read_only_permissions = original_permissions.clone();
read_only_permissions.set_readonly(true);
std::fs::set_permissions(&path, read_only_permissions).unwrap();
let main_before = std::fs::read(&path).unwrap();
let entries_before = directory_entries(dir.path());
let pool = ConnectionPool::new(PoolConfig {
path: Some(path.clone()),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.unwrap();
let reader = pool.reader().unwrap();
let body: String = reader
.conn()
.query_row("SELECT body FROM snapshot_row", [], |row| row.get(0))
.unwrap();
assert_eq!(body, "checkpointed");
drop(reader);
let standalone = pool
.open_standalone_reader(StandaloneReaderPurpose::DiagnosticsIndependentSnapshot)
.unwrap();
let count: i64 = standalone
.query_row("SELECT COUNT(*) FROM snapshot_row", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
drop(standalone);
drop(pool);
assert_eq!(std::fs::read(&path).unwrap(), main_before);
assert_eq!(directory_entries(dir.path()), entries_before);
assert!(!wal.exists() && !shm.exists());
std::fs::set_permissions(&path, original_permissions).unwrap();
}
#[test]
fn read_only_live_rollback_journal_keeps_change_detection() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("live-delete-journal.db");
let writer = Connection::open(&path).unwrap();
writer
.execute_batch("CREATE TABLE live_row(id INTEGER PRIMARY KEY);")
.unwrap();
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.unwrap();
{
let reader = pool.reader().unwrap();
let count: i64 = reader
.conn()
.query_row("SELECT COUNT(*) FROM live_row", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
writer
.execute("INSERT INTO live_row DEFAULT VALUES", [])
.unwrap();
let reader = pool.reader().unwrap();
let count: i64 = reader
.conn()
.query_row("SELECT COUNT(*) FROM live_row", [], |row| row.get(0))
.unwrap();
assert_eq!(
count, 1,
"rollback-journal read-only connections must retain live change detection"
);
}
#[test]
fn pooled_reader_return_clears_temp_schema_and_attached_databases() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pooled-reader-reset.db");
{
let seed = Connection::open(&path).unwrap();
seed.execute_batch("CREATE TABLE main_row(id INTEGER PRIMARY KEY);")
.unwrap();
}
let secret_path = dir.path().join("secret.db");
{
let secret = Connection::open(&secret_path).unwrap();
secret
.execute_batch(
"CREATE TABLE secret_row(id INTEGER PRIMARY KEY, body TEXT NOT NULL);\
INSERT INTO secret_row(body) VALUES ('leaked-across-checkouts');",
)
.unwrap();
}
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
max_readers: 1,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.unwrap();
{
let reader = pool.reader().unwrap();
reader
.conn()
.execute_batch("CREATE TEMP TABLE leaked_temp(id INTEGER PRIMARY KEY);")
.unwrap();
reader
.conn()
.execute_batch(&format!(
"ATTACH DATABASE '{}' AS secret;",
secret_path.display()
))
.unwrap();
let leaked_count: i64 = reader
.conn()
.query_row("SELECT COUNT(*) FROM secret.secret_row", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(leaked_count, 1);
reader.mark_dirty();
}
let reader = pool.reader().unwrap();
let temp_table_survived: i64 = reader
.conn()
.query_row(
"SELECT COUNT(*) FROM sqlite_temp_master WHERE name = 'leaked_temp'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
temp_table_survived, 0,
"a TEMP table from an earlier checkout must not survive pooled reader reuse"
);
let attachment_survived: i64 = reader
.conn()
.query_row(
"SELECT COUNT(*) FROM pragma_database_list WHERE name = 'secret'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
attachment_survived, 0,
"an ATTACHed database from an earlier checkout must not survive pooled reader reuse"
);
}
#[test]
fn writable_schema_evasion_of_the_temp_catalog_scan_still_disqualifies_reuse() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("writable-schema-evasion.db");
{
let seed = Connection::open(&path).unwrap();
seed.execute_batch("CREATE TABLE main_row(id INTEGER PRIMARY KEY);")
.unwrap();
}
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
max_readers: 1,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.unwrap();
{
let reader = pool.reader().unwrap();
reader
.conn()
.execute_batch("CREATE TEMP TABLE leaked_temp(id INTEGER PRIMARY KEY);")
.unwrap();
reader
.conn()
.execute_batch(
"PRAGMA writable_schema = ON; \
DELETE FROM sqlite_temp_master WHERE name = 'leaked_temp';",
)
.unwrap();
let visible: i64 = reader
.conn()
.query_row(
"SELECT COUNT(*) FROM sqlite_temp_master WHERE name = 'leaked_temp'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
visible, 0,
"the evasion must actually hide the row from the catalog scan"
);
reader.mark_dirty();
}
let reader = pool.reader().unwrap();
let leaked_still_queryable = reader
.conn()
.query_row("SELECT COUNT(*) FROM leaked_temp", [], |row| {
row.get::<_, i64>(0)
})
.is_ok();
assert!(
!leaked_still_queryable,
"a writable_schema evasion of the catalog scan must still disqualify the \
connection via the settings check"
);
}
#[test]
fn a_checkout_that_never_marks_dirty_skips_the_catalog_scan_entirely() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("typed-read-skips-scan.db");
{
let seed = Connection::open(&path).unwrap();
seed.execute_batch("CREATE TABLE main_row(id INTEGER PRIMARY KEY);")
.unwrap();
}
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
max_readers: 1,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.unwrap();
{
let reader = pool.reader().unwrap();
reader
.conn()
.execute_batch("CREATE TEMP TABLE survivor(id INTEGER PRIMARY KEY);")
.unwrap();
}
let reader = pool.reader().unwrap();
let survived = reader
.conn()
.query_row("SELECT COUNT(*) FROM survivor", [], |row| {
row.get::<_, i64>(0)
})
.is_ok();
assert!(
survived,
"a non-dirty checkout must return without running the catalog scan at all, \
so a TEMP table it left behind is still visible on the next checkout"
);
}
#[test]
fn busy_timeout_and_cache_size_changes_do_not_survive_a_dirty_pooled_checkout() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings-evasion.db");
{
let seed = Connection::open(&path).unwrap();
seed.execute_batch("CREATE TABLE main_row(id INTEGER PRIMARY KEY);")
.unwrap();
}
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
max_readers: 1,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.unwrap();
let default_busy_timeout_ms = i64::try_from(pool.config().busy_timeout.as_millis())
.expect("configured busy_timeout fits i64 millis");
{
let reader = pool.reader().unwrap();
reader
.conn()
.pragma_update(None, "busy_timeout", 1i64)
.unwrap();
reader
.conn()
.pragma_update(None, "cache_size", -64i64)
.unwrap();
reader
.conn()
.query_row("SELECT 1", [], |row| row.get::<_, i64>(0))
.unwrap();
reader.mark_dirty();
}
let reader = pool.reader().unwrap();
let busy_timeout: i64 = reader
.conn()
.query_row("PRAGMA busy_timeout", [], |row| row.get(0))
.unwrap();
let cache_size: i64 = reader
.conn()
.query_row("PRAGMA cache_size", [], |row| row.get(0))
.unwrap();
assert_eq!(
busy_timeout, default_busy_timeout_ms,
"busy_timeout must be restored to the pool's configured baseline"
);
assert_eq!(
cache_size, -65536,
"cache_size must be restored to the pool's configured baseline"
);
}
#[test]
fn degraded_shared_reader_lease_clears_temp_schema_and_attached_databases_on_dirty_return() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("degraded-shared-reader-reset.db");
{
let seed = Connection::open(&path).unwrap();
seed.execute_batch("CREATE TABLE main_row(id INTEGER PRIMARY KEY);")
.unwrap();
}
let secret_path = dir.path().join("secret.db");
{
let secret = Connection::open(&secret_path).unwrap();
secret
.execute_batch(
"CREATE TABLE secret_row(id INTEGER PRIMARY KEY, body TEXT NOT NULL);\
INSERT INTO secret_row(body) VALUES ('leaked-across-checkouts');",
)
.unwrap();
}
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
max_readers: 0,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.unwrap();
{
let reader = pool.reader().unwrap();
reader
.conn()
.execute_batch("CREATE TEMP TABLE leaked_temp(id INTEGER PRIMARY KEY);")
.unwrap();
reader
.conn()
.execute_batch(&format!(
"ATTACH DATABASE '{}' AS secret;",
secret_path.display()
))
.unwrap();
reader.mark_dirty();
}
let reader = pool.reader().unwrap();
let temp_table_survived: i64 = reader
.conn()
.query_row(
"SELECT COUNT(*) FROM sqlite_temp_master WHERE name = 'leaked_temp'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
temp_table_survived, 0,
"a TEMP table must not survive a dirty degraded shared-reader-lease return"
);
let attachment_survived: i64 = reader
.conn()
.query_row(
"SELECT COUNT(*) FROM pragma_database_list WHERE name = 'secret'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
attachment_survived, 0,
"an ATTACHed database must not survive a dirty degraded shared-reader-lease return"
);
}
#[test]
fn query_row_refuses_write_and_transaction_control_statements() {
let pool = ConnectionPool::new(PoolConfig {
path: None,
..PoolConfig::default()
})
.unwrap();
pool.writer()
.unwrap()
.conn()
.execute_batch("CREATE TABLE query_row_admission_probe(id INTEGER PRIMARY KEY);")
.unwrap();
let reader = pool.reader().unwrap();
for (label, sql) in [
("BEGIN", "BEGIN"),
(
"INSERT",
"INSERT INTO query_row_admission_probe(id) VALUES (1)",
),
(
"CREATE TEMP TABLE",
"CREATE TEMP TABLE query_row_admission_probe_temp(id INTEGER PRIMARY KEY)",
),
("setting PRAGMA", "PRAGMA journal_mode = OFF"),
] {
let result = reader.query_row(sql, [], |row| row.get::<_, i64>(0));
assert!(
result.is_err(),
"query_row must refuse {label} ({sql:?}); got {result:?}"
);
}
let row_count: i64 = reader
.query_row(
"SELECT COUNT(*) FROM query_row_admission_probe",
[],
|row| row.get(0),
)
.expect("an admitted SELECT must still succeed");
assert_eq!(
row_count, 0,
"a refused INSERT must never have reached SQLite"
);
let temp_table_survived: i64 = reader
.query_row(
"SELECT COUNT(*) FROM sqlite_temp_master \
WHERE name = 'query_row_admission_probe_temp'",
[],
|row| row.get(0),
)
.expect("an admitted SELECT must still succeed");
assert_eq!(
temp_table_survived, 0,
"a refused CREATE TEMP TABLE must never have reached SQLite"
);
let journal_mode: String = reader
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.expect("the read-only journal_mode PRAGMA form must still be admitted");
assert_ne!(
journal_mode.to_ascii_lowercase(),
"off",
"a refused setting PRAGMA must never have reached SQLite"
);
}
#[test]
fn discarded_reader_replacement_open_failure_is_recorded() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("discard-replacement-failure.db");
{
let seed = Connection::open(&path).unwrap();
seed.execute_batch("CREATE TABLE t(id INTEGER PRIMARY KEY);")
.unwrap();
}
let pool = ConnectionPool::new(PoolConfig {
path: Some(path.clone()),
max_readers: 1,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.unwrap();
let before = pool.reader_acquisition_snapshot();
let reader = pool.reader().unwrap();
reader.discard();
std::fs::remove_file(&path).unwrap();
for suffix in ["-wal", "-shm"] {
let _ = std::fs::remove_file(sqlite_sidecar(&path, suffix));
}
drop(reader);
let after = pool.reader_acquisition_snapshot();
assert_eq!(
after.reader_replacement_open_failures - before.reader_replacement_open_failures,
1,
"a non-reusable checkout's failed replacement open must be recorded, not silently \
swallowed"
);
}
#[test]
fn read_only_live_wal_with_writable_shm_is_refused_without_mutation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("live-wal.db");
let wal = sqlite_sidecar(&path, "-wal");
let shm = sqlite_sidecar(&path, "-shm");
let writer = Connection::open(&path).unwrap();
writer.pragma_update(None, "journal_mode", "WAL").unwrap();
writer
.execute_batch(
"CREATE TABLE live_row(id INTEGER PRIMARY KEY);\
INSERT INTO live_row DEFAULT VALUES;",
)
.unwrap();
assert!(wal.exists() && shm.exists());
let main_before = std::fs::read(&path).unwrap();
let wal_before = std::fs::read(&wal).unwrap();
let shm_before = std::fs::read(&shm).unwrap();
let error = match ConnectionPool::new(PoolConfig {
path: Some(path.clone()),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
}) {
Ok(_) => panic!("a live WAL database with writable -shm must fail closed"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("writable WAL shared-memory sidecar"),
"diagnostic must explain how to freeze the snapshot: {error}"
);
assert_eq!(std::fs::read(&path).unwrap(), main_before);
assert_eq!(std::fs::read(&wal).unwrap(), wal_before);
assert_eq!(std::fs::read(&shm).unwrap(), shm_before);
drop(writer);
}
#[test]
fn pool_drop_never_leaves_a_reader_as_the_last_connection_closed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("close-order.db");
let wal = sqlite_sidecar(&path, "-wal");
let shm = sqlite_sidecar(&path, "-shm");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path.clone()),
max_readers: 1,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.unwrap();
pool.writer()
.unwrap()
.execute_batch(
"CREATE TABLE close_order_row(id INTEGER PRIMARY KEY);\
INSERT INTO close_order_row DEFAULT VALUES;",
)
.unwrap();
assert!(
wal.exists(),
"a WAL-mode write must leave a -wal sidecar before the pool drops"
);
drop(pool);
assert!(
!wal.exists(),
"the pool's last connection to close must be writable enough to checkpoint -wal away"
);
assert!(
!shm.exists(),
"the pool's last connection to close must be writable enough to checkpoint -shm away"
);
}
#[cfg(unix)]
#[test]
fn read_only_live_wal_symlink_rejects_target_writable_shm_without_mutation() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("live-target.db");
let alias = dir.path().join("live-alias.db");
let target_wal = sqlite_sidecar(&target, "-wal");
let target_shm = sqlite_sidecar(&target, "-shm");
let alias_wal = sqlite_sidecar(&alias, "-wal");
let alias_shm = sqlite_sidecar(&alias, "-shm");
let writer = Connection::open(&target).unwrap();
writer.pragma_update(None, "journal_mode", "WAL").unwrap();
writer
.execute_batch(
"CREATE TABLE live_row(id INTEGER PRIMARY KEY);\
INSERT INTO live_row DEFAULT VALUES;",
)
.unwrap();
assert!(target_wal.exists() && target_shm.exists());
symlink(&target, &alias).unwrap();
assert!(!alias_wal.exists() && !alias_shm.exists());
let main_before = std::fs::read(&target).unwrap();
let wal_before = std::fs::read(&target_wal).unwrap();
let shm_before = std::fs::read(&target_shm).unwrap();
let entries_before = directory_entries(dir.path());
let error = match ConnectionPool::new(PoolConfig {
path: Some(alias),
read_only: true,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
}) {
Ok(_) => panic!("a symlink must not hide the target's writable -shm"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("writable WAL shared-memory sidecar"),
"diagnostic must identify the canonical target's live sidecar: {error}"
);
assert_eq!(std::fs::read(&target).unwrap(), main_before);
assert_eq!(std::fs::read(&target_wal).unwrap(), wal_before);
assert_eq!(std::fs::read(&target_shm).unwrap(), shm_before);
assert_eq!(directory_entries(dir.path()), entries_before);
assert!(!alias_wal.exists() && !alias_shm.exists());
drop(writer);
}
#[test]
#[serial]
fn pool_config_default_values_match_constants() {
let _pool_env = clear_pool_env();
let cfg = PoolConfig::default();
assert_eq!(
cfg.journal_size_limit_bytes,
DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
);
assert_eq!(cfg.busy_timeout, Duration::from_secs(30));
assert_eq!(cfg.checkout_timeout, Duration::from_secs(5));
}
#[test]
#[serial]
fn legacy_env_cannot_change_wal_autocheckpoint() {
let _pool_env = clear_pool_env();
std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "8000");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy_autocheckpoint_env.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
..PoolConfig::for_test()
})
.expect("pool open");
{
let writer = pool.writer().expect("writer");
assert_eq!(
wal_autocheckpoint_pages(writer.conn()),
FALLBACK_WAL_AUTOCHECKPOINT_PAGES,
"the removed env override must not change the unclaimed fallback"
);
}
pool.claim_checkpoint_ownership().expect("claim ownership");
let writer = pool.writer().expect("writer after claim");
assert_eq!(
wal_autocheckpoint_pages(writer.conn()),
0,
"the removed env override must not change the claimed-owner setting"
);
std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
}
#[test]
#[serial]
fn pool_config_env_override_journal_size_limit() {
std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "134217728");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
assert_eq!(cfg.journal_size_limit_bytes, 134_217_728);
}
#[test]
#[serial]
fn pool_config_env_override_busy_timeout() {
std::env::set_var("KHIVE_BUSY_TIMEOUT_SECS", "60");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_BUSY_TIMEOUT_SECS");
assert_eq!(cfg.busy_timeout, Duration::from_secs(60));
}
#[test]
#[serial]
fn pool_config_env_override_checkout_timeout() {
std::env::set_var("KHIVE_CHECKOUT_TIMEOUT_SECS", "10");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_CHECKOUT_TIMEOUT_SECS");
assert_eq!(cfg.checkout_timeout, Duration::from_secs(10));
}
#[test]
#[serial]
fn pool_config_write_queue_defaults_unset() {
let _pool_env = clear_pool_env();
let cfg = PoolConfig::default();
assert_eq!(cfg.write_queue_enabled, None);
assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
}
#[test]
#[serial]
fn clear_pool_env_restores_overrides_on_drop() {
let _ambient_env = PoolEnvGuard::capture();
std::env::set_var("KHIVE_BUSY_TIMEOUT_SECS", "73");
{
let _pool_env = clear_pool_env();
assert_eq!(std::env::var_os("KHIVE_BUSY_TIMEOUT_SECS"), None);
}
assert_eq!(
std::env::var_os("KHIVE_BUSY_TIMEOUT_SECS"),
Some(std::ffi::OsString::from("73"))
);
}
#[test]
#[serial]
fn pool_config_env_override_write_queue_enabled() {
std::env::set_var("KHIVE_WRITE_QUEUE", "1");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_QUEUE");
assert_eq!(cfg.write_queue_enabled, Some(true));
}
#[test]
#[serial]
fn pool_config_env_override_write_queue_enabled_accepts_true_case_insensitive() {
std::env::set_var("KHIVE_WRITE_QUEUE", "True");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_QUEUE");
assert_eq!(cfg.write_queue_enabled, Some(true));
}
#[test]
#[serial]
fn pool_config_env_override_write_queue_enabled_accepts_zero_as_explicit_off() {
std::env::set_var("KHIVE_WRITE_QUEUE", "0");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_QUEUE");
assert_eq!(cfg.write_queue_enabled, Some(false));
}
#[cfg(unix)]
#[test]
#[serial]
fn pool_config_env_override_write_queue_non_unicode_value_is_explicit_off() {
use std::os::unix::ffi::OsStrExt;
let _pool_env = clear_pool_env();
std::env::set_var(
"KHIVE_WRITE_QUEUE",
std::ffi::OsStr::from_bytes(b"\xff\xfe"),
);
let cfg = PoolConfig::default();
assert_eq!(cfg.write_queue_enabled, Some(false));
}
#[test]
#[serial]
fn pool_config_env_override_write_queue_invalid_value_is_explicit_off() {
std::env::set_var("KHIVE_WRITE_QUEUE", "banana");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_QUEUE");
assert_eq!(cfg.write_queue_enabled, Some(false));
}
#[test]
#[serial]
fn pool_config_write_routing_strict_defaults_off() {
let _pool_env = clear_pool_env();
let cfg = PoolConfig::default();
assert!(!cfg.write_routing_strict);
}
#[test]
#[serial]
fn pool_config_env_override_write_routing_strict() {
std::env::set_var("KHIVE_WRITE_ROUTING", "strict");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_ROUTING");
assert!(cfg.write_routing_strict);
}
#[test]
#[serial]
fn pool_config_env_override_write_routing_strict_case_insensitive() {
std::env::set_var("KHIVE_WRITE_ROUTING", "STRICT");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_ROUTING");
assert!(cfg.write_routing_strict);
}
#[test]
#[serial]
fn pool_config_env_write_routing_ignores_unrecognized_value() {
std::env::set_var("KHIVE_WRITE_ROUTING", "eventual");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_ROUTING");
assert!(!cfg.write_routing_strict);
}
#[test]
#[serial]
fn pool_config_env_override_write_queue_capacity() {
std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "64");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
assert_eq!(cfg.write_queue_capacity, 64);
}
#[test]
#[serial]
fn pool_config_env_invalid_write_queue_capacity_falls_back_to_default() {
std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "0");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
}
#[test]
#[serial]
fn pool_config_invalid_journal_size_limit_falls_back_to_default() {
std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "");
let cfg = PoolConfig::default();
std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
assert_eq!(
cfg.journal_size_limit_bytes,
DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
);
}
#[test]
fn file_backed_pool_opens_successfully() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test_pool.db");
let cfg = PoolConfig {
path: Some(path.clone()),
..PoolConfig::default()
};
let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
assert!(path.exists());
assert!(pool.max_readers() > 0);
}
#[test]
fn standalone_wal_writer_uses_configured_journal_size_limit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("standalone_wal_journal_limit.db");
let configured_limit = 12_345_678;
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
journal_size_limit_bytes: configured_limit,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("WAL pool open");
let standalone = pool
.open_standalone_writer_untracked()
.expect("standalone WAL writer open");
assert_eq!(current_journal_mode(&standalone).unwrap(), "wal");
assert_eq!(journal_size_limit_bytes(&standalone), configured_limit);
}
#[test]
fn standalone_rollback_writer_keeps_sqlite_journal_size_limit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("standalone_rollback_journal_limit.db");
let sqlite_default = {
let conn = Connection::open(&path).expect("seed rollback-journal database");
assert_eq!(current_journal_mode(&conn).unwrap(), "delete");
journal_size_limit_bytes(&conn)
};
let configured_limit = if sqlite_default == 12_345_678 {
23_456_789
} else {
12_345_678
};
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
wal_mode: false,
journal_size_limit_bytes: configured_limit,
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("rollback-journal pool open");
let standalone = pool
.open_standalone_writer_untracked()
.expect("standalone rollback-journal writer open");
assert_eq!(current_journal_mode(&standalone).unwrap(), "delete");
assert_eq!(journal_size_limit_bytes(&standalone), sqlite_default);
}
#[test]
fn writer_connections_follow_checkpoint_ownership_claim() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("writer_autocheckpoint.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("pool open");
{
let writer = pool.writer().expect("pooled writer");
assert_eq!(
wal_autocheckpoint_pages(writer.conn()),
FALLBACK_WAL_AUTOCHECKPOINT_PAGES
);
}
let standalone = pool
.open_standalone_writer()
.expect("standalone writer opened before any claim");
assert_eq!(
wal_autocheckpoint_pages(&standalone),
FALLBACK_WAL_AUTOCHECKPOINT_PAGES
);
drop(standalone);
pool.claim_checkpoint_ownership().expect("claim ownership");
{
let writer = pool.writer().expect("pooled writer after claim");
assert_eq!(wal_autocheckpoint_pages(writer.conn()), 0);
}
let claimed_standalone = pool
.open_standalone_writer()
.expect("standalone writer opened after the claim");
assert_eq!(wal_autocheckpoint_pages(&claimed_standalone), 0);
drop(claimed_standalone);
let later_infrastructure = pool
.open_standalone_writer_untracked()
.expect("later infrastructure writer");
assert_eq!(wal_autocheckpoint_pages(&later_infrastructure), 0);
let memory_pool = ConnectionPool::new(PoolConfig {
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.expect("in-memory pool open");
let memory_writer = memory_pool.writer().expect("in-memory writer");
assert_eq!(
wal_autocheckpoint_pages(memory_writer.conn()),
FALLBACK_WAL_AUTOCHECKPOINT_PAGES,
"an unclaimed in-memory pool keeps the bounded fallback"
);
}
#[test]
fn standalone_writer_waits_for_checkpoint_claim_resolution() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint_claim_race.db");
let pool = Arc::new(
ConnectionPool::new(PoolConfig {
path: Some(path),
checkout_timeout: Duration::from_secs(5),
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("pool open"),
);
let legacy_conn = pool.legacy_conn();
let held_writer = legacy_conn.lock();
let claim_start = Arc::new(std::sync::Barrier::new(2));
let claim_pool = Arc::clone(&pool);
let claim_thread_start = Arc::clone(&claim_start);
let claim_thread = thread::spawn(move || {
claim_thread_start.wait();
claim_pool.claim_checkpoint_ownership()
});
claim_start.wait();
{
let mut state = pool.checkpoint_ownership.state.lock();
while state.phase != CheckpointOwnership::Claiming {
pool.checkpoint_ownership.changed.wait(&mut state);
}
}
let open_start = Arc::new(std::sync::Barrier::new(2));
let open_pool = Arc::clone(&pool);
let open_thread_start = Arc::clone(&open_start);
let open_thread = thread::spawn(move || {
open_thread_start.wait();
let conn = open_pool
.open_standalone_writer()
.expect("standalone writer after claim resolution");
wal_autocheckpoint_pages(&conn)
});
open_start.wait();
{
let mut state = pool.checkpoint_ownership.state.lock();
while state.connection_waiters == 0 {
pool.checkpoint_ownership.changed.wait(&mut state);
}
assert_eq!(state.phase, CheckpointOwnership::Claiming);
}
drop(held_writer);
claim_thread
.join()
.expect("claim thread joins")
.expect("claim succeeds");
assert_eq!(
open_thread.join().expect("standalone-open thread joins"),
0,
"a writer open concurrent with a successful claim must inherit claimed ownership"
);
}
#[test]
fn standalone_fallback_application_linearizes_before_claim_publication() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint_open_before_claim.db");
let pool = Arc::new(
ConnectionPool::new(PoolConfig {
path: Some(path),
checkout_timeout: Duration::from_secs(5),
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("pool open"),
);
let pause = Arc::new(CheckpointConnectionConfigPause::new());
*pool.checkpoint_ownership.connection_config_pause.lock() = Some(Arc::clone(&pause));
let open_pool = Arc::clone(&pool);
let open_thread = thread::spawn(move || {
let conn = open_pool
.open_standalone_writer()
.expect("standalone writer opens");
wal_autocheckpoint_pages(&conn)
});
pause.selected.wait();
assert!(
pool.checkpoint_ownership.state.try_lock().is_none(),
"standalone selection must retain the ownership gate until its PRAGMA is applied"
);
let (claim_observed_tx, claim_observed_rx) = std::sync::mpsc::sync_channel(0);
*pool.checkpoint_ownership.claim_lock_observed.lock() = Some(claim_observed_tx);
let claim_pool = Arc::clone(&pool);
let claim_thread = thread::spawn(move || claim_pool.claim_checkpoint_ownership());
assert!(
claim_observed_rx
.recv()
.expect("claim reports whether it observed gate contention"),
"the claim must attempt the gate between fallback selection and PRAGMA application"
);
pause.resume.wait();
assert_eq!(
open_thread.join().expect("standalone-open thread joins"),
FALLBACK_WAL_AUTOCHECKPOINT_PAGES,
"an open linearized before the claim keeps the fallback"
);
claim_thread
.join()
.expect("claim thread joins")
.expect("claim succeeds after standalone configuration");
assert_eq!(pool.effective_wal_autocheckpoint_pages(), 0);
}
#[test]
fn failed_checkpoint_ownership_claim_keeps_fallback_and_can_be_retried() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint_claim_retry.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
checkout_timeout: Duration::from_millis(1),
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("pool open");
let legacy_conn = pool.legacy_conn();
let held_writer = legacy_conn.lock();
let error = pool
.claim_checkpoint_ownership()
.expect_err("the held pooled writer must make the claim time out");
assert!(matches!(
error,
SqliteError::WriterPoolCheckoutTimeout { .. }
));
assert_eq!(
pool.effective_wal_autocheckpoint_pages(),
FALLBACK_WAL_AUTOCHECKPOINT_PAGES,
"a failed claim must leave later writer connections fallback-safe"
);
let fallback_writer = pool
.open_standalone_writer()
.expect("standalone writer after failed claim");
assert_eq!(
wal_autocheckpoint_pages(&fallback_writer),
FALLBACK_WAL_AUTOCHECKPOINT_PAGES
);
drop(fallback_writer);
drop(held_writer);
pool.claim_checkpoint_ownership()
.expect("the ownership claim remains retryable");
assert_eq!(pool.effective_wal_autocheckpoint_pages(), 0);
let writer = pool.writer().expect("pooled writer after successful retry");
assert_eq!(wal_autocheckpoint_pages(writer.conn()), 0);
}
#[test]
fn threshold_crossing_commits_do_not_run_an_implicit_checkpoint_once_claimed() {
const FORMER_AUTOCHECKPOINT_THRESHOLD_PAGES: i64 = FALLBACK_WAL_AUTOCHECKPOINT_PAGES as i64;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no_implicit_checkpoint.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("pool open");
pool.claim_checkpoint_ownership()
.expect("claim ownership for the dedicated-owner posture");
let writer = pool.writer().expect("pooled writer");
writer
.execute_batch("CREATE TABLE blobs (value BLOB NOT NULL)")
.expect("create fixture table");
let page_size: i64 = writer
.pragma_query_value(None, "page_size", |row| row.get(0))
.expect("read page size");
let payload_bytes = page_size * 32;
for _ in 0..160 {
writer
.execute(
"INSERT INTO blobs (value) VALUES (zeroblob(?1))",
[payload_bytes],
)
.expect("autocommit fixture row");
}
let log_frames: i64 = writer
.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| row.get(1))
.expect("observe WAL frame count");
assert!(
log_frames > FORMER_AUTOCHECKPOINT_THRESHOLD_PAGES,
"the commit sequence must retain more than the former automatic threshold; \
observed {log_frames} frames"
);
}
#[test]
fn unclaimed_pool_retains_bounded_autocheckpoint_reclamation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bounded_fallback_reclamation.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("pool open");
let writer = pool.writer().expect("pooled writer");
writer
.execute_batch("CREATE TABLE blobs (value BLOB NOT NULL)")
.expect("create fixture table");
let page_size: i64 = writer
.pragma_query_value(None, "page_size", |row| row.get(0))
.expect("read page size");
let payload_bytes = page_size * 32;
for _ in 0..160 {
writer
.execute(
"INSERT INTO blobs (value) VALUES (zeroblob(?1))",
[payload_bytes],
)
.expect("autocommit fixture row");
}
let log_frames: i64 = writer
.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| row.get(1))
.expect("observe WAL frame count");
assert!(
log_frames < FALLBACK_WAL_AUTOCHECKPOINT_PAGES as i64,
"an unclaimed pool must reclaim WAL frames via the bounded autocheckpoint; \
observed {log_frames} retained frames"
);
}
#[tokio::test]
#[serial]
async fn unset_write_queue_resolves_on_for_file_backed_pool() {
let _pool_env = clear_pool_env();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("unset_file_backed.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: None,
..PoolConfig::for_test()
})
.expect("file-backed pool should open");
assert_eq!(pool.config().write_queue_enabled, Some(true));
assert!(
pool.writer_task_handle()
.expect("spawn inside a runtime context must not error")
.is_some(),
"resolved-on file-backed pool must actually spawn the writer task"
);
}
#[tokio::test]
#[serial]
async fn unset_write_queue_resolves_off_for_memory_backed_pool() {
let _pool_env = clear_pool_env();
let pool = ConnectionPool::new(PoolConfig {
path: None,
write_queue_enabled: None,
..PoolConfig::default()
})
.expect("in-memory pool should open");
assert_eq!(pool.config().write_queue_enabled, Some(false));
assert!(
pool.writer_task_handle()
.expect("disabled queue must resolve without error")
.is_none(),
"resolved-off in-memory pool must not spawn a writer task"
);
}
#[test]
#[serial]
fn explicit_false_stays_off_for_file_backed_pool() {
let _pool_env = clear_pool_env();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("explicit_false_file_backed.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: Some(false),
..PoolConfig::for_test()
})
.expect("file-backed pool should open");
assert_eq!(pool.config().write_queue_enabled, Some(false));
}
#[tokio::test]
#[serial]
async fn explicit_true_stays_on_for_memory_backed_pool() {
let _pool_env = clear_pool_env();
let pool = ConnectionPool::new(PoolConfig {
path: None,
write_queue_enabled: Some(true),
..PoolConfig::default()
})
.expect("in-memory pool should open");
assert_eq!(pool.config().write_queue_enabled, Some(true));
assert!(
pool.writer_task_handle()
.expect("spawn degrade must resolve without error")
.is_none(),
"explicit-on in-memory pool must degrade to no writer task"
);
assert_eq!(
pool.writer_task_spawn_count(),
1,
"the spawn attempt must happen exactly once and degrade, not retry"
);
assert!(
pool.take_writer_task_join().is_none(),
"a degraded spawn stores no JoinHandle to drain"
);
}
#[test]
#[serial]
fn explicit_true_on_memory_pool_warns_but_false_and_none_do_not() {
let _pool_env = clear_pool_env();
let messages = Arc::new(std::sync::Mutex::new(Vec::new()));
let subscriber = WarningCapture {
messages: Arc::clone(&messages),
};
tracing::subscriber::with_default(subscriber, || {
let _explicit_true = ConnectionPool::new(PoolConfig {
path: None,
write_queue_enabled: Some(true),
..PoolConfig::default()
})
.expect("in-memory pool should open");
let _explicit_false = ConnectionPool::new(PoolConfig {
path: None,
write_queue_enabled: Some(false),
..PoolConfig::default()
})
.expect("in-memory pool should open");
let _unset = ConnectionPool::new(PoolConfig {
path: None,
write_queue_enabled: None,
..PoolConfig::default()
})
.expect("in-memory pool should open");
});
let messages = messages.lock().unwrap();
assert_eq!(
messages
.iter()
.filter(|message| message.contains("write queue explicitly requested"))
.count(),
1,
"only an explicit in-memory queue request should warn: {messages:?}"
);
let warning = messages
.iter()
.find(|message| message.contains("write queue explicitly requested"))
.expect("explicit in-memory queue warning should be captured");
assert!(
warning.contains("in-memory pools cannot host a writer task"),
"warning must explain why the request is inert: {messages:?}"
);
}
#[test]
fn standalone_writer_open_counts_its_connection_class_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("standalone_writer_counter.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
..PoolConfig::for_test()
})
.expect("file-backed pool");
let _standalone = pool
.open_standalone_writer()
.expect("standalone writer opens");
assert_eq!(
pool.writer_acquisition_snapshot(),
WriterAcquisitionSnapshot {
acquisitions: 1,
pooled_acquisitions: 0,
standalone_acquisitions: 1,
writer_task_acquisitions: 0,
timeouts: 0,
writer_task_begin_busy: 0,
writer_task_begin_busy_absorbed: 0,
writer_task_begin_errors: 0,
writer_task_request_failures: 0,
writer_task_side_effects_unknown: 0,
},
"the public standalone boundary must contribute to the aggregate exactly once"
);
}
#[test]
fn reader_snapshot_tracks_pool_saturation_hold_lifecycle_and_exception_classes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("reader_acquisition_counters.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
max_readers: 1,
checkout_timeout: Duration::from_millis(2),
..PoolConfig::default()
})
.expect("file-backed pool");
assert_eq!(
pool.reader_acquisition_snapshot(),
ReaderAcquisitionSnapshot {
reader_admission_capacity: 1,
available_reader_admission_slots: 1,
..ReaderAcquisitionSnapshot::default()
}
);
let held = pool.reader().expect("first pooled checkout succeeds");
assert_eq!(
pool.reader_acquisition_snapshot(),
ReaderAcquisitionSnapshot {
reader_admission_capacity: 1,
available_reader_admission_slots: 0,
acquisitions: 1,
pooled_checkouts: 1,
active_pooled_checkouts: 1,
peak_active_pooled_checkouts: 1,
..ReaderAcquisitionSnapshot::default()
}
);
let timeout = match pool.reader() {
Ok(_) => panic!("the sole live checkout must exhaust bounded admission"),
Err(error) => error,
};
assert!(
matches!(
&timeout,
SqliteError::Rusqlite(rusqlite::Error::SqliteFailure(code, _))
if code.code == rusqlite::ErrorCode::DatabaseBusy
),
"reader saturation must keep the pool-exhausted classification: {timeout}"
);
drop(held);
let explicit = pool
.open_standalone_reader(StandaloneReaderPurpose::ExplicitSqlReadTransaction)
.expect("explicit read-transaction exception opens");
drop(explicit);
let infrastructure = pool
.open_standalone_reader(StandaloneReaderPurpose::DiagnosticsIndependentSnapshot)
.expect("infrastructure exception opens");
drop(infrastructure);
let snapshot = pool.reader_acquisition_snapshot();
assert_eq!(snapshot.acquisitions, 2);
assert_eq!(snapshot.pooled_checkouts, 1);
assert_eq!(snapshot.standalone_opens, 1);
assert_eq!(snapshot.infrastructure_standalone_opens, 1);
assert_eq!(snapshot.checkout_timeouts, 1);
assert_eq!(snapshot.active_pooled_checkouts, 0);
assert_eq!(snapshot.peak_active_pooled_checkouts, 1);
assert_eq!(snapshot.completed_pooled_checkouts, 1);
assert!(
snapshot.max_completed_hold_micros > 0,
"the held checkout's completed lifecycle must expose nonzero hold evidence"
);
}
#[test]
fn in_memory_pool_degrades_to_single_connection() {
let cfg = PoolConfig {
path: None,
..PoolConfig::default()
};
let pool = ConnectionPool::new(cfg).expect("in-memory pool should open");
assert_eq!(pool.max_readers(), 0);
}
#[test]
fn writer_checkout_and_release_works() {
let cfg = PoolConfig {
path: None,
..PoolConfig::default()
};
let pool = ConnectionPool::new(cfg).unwrap();
{
let _writer = pool.writer().expect("writer checkout should succeed");
}
let _writer2 = pool
.writer()
.expect("second writer checkout should succeed");
}
#[test]
fn writer_checkout_snapshot_counts_successes_and_timeouts_at_the_pool_boundary() {
let cfg = PoolConfig {
path: None,
checkout_timeout: Duration::from_millis(1),
..PoolConfig::default()
};
let pool = ConnectionPool::new(cfg).unwrap();
assert_eq!(
pool.writer_acquisition_snapshot(),
WriterAcquisitionSnapshot::default()
);
let held = pool.writer().expect("first checkout succeeds");
let error = match pool.writer() {
Ok(_) => panic!("the held pool mutex must force a finite-wait timeout"),
Err(error) => error,
};
assert!(
matches!(
&error,
SqliteError::WriterPoolCheckoutTimeout { timeout }
if *timeout == Duration::from_millis(1)
),
"timeout must have a stable, structurally matchable stage: {error}"
);
assert_eq!(
pool.writer_acquisition_snapshot(),
WriterAcquisitionSnapshot {
acquisitions: 1,
pooled_acquisitions: 1,
standalone_acquisitions: 0,
writer_task_acquisitions: 0,
timeouts: 1,
writer_task_begin_busy: 0,
writer_task_begin_busy_absorbed: 0,
writer_task_begin_errors: 0,
writer_task_request_failures: 0,
writer_task_side_effects_unknown: 0,
}
);
drop(held);
let _reacquired = pool.writer().expect("checkout succeeds after release");
assert_eq!(
pool.writer_acquisition_snapshot(),
WriterAcquisitionSnapshot {
acquisitions: 2,
pooled_acquisitions: 2,
standalone_acquisitions: 0,
writer_task_acquisitions: 0,
timeouts: 1,
writer_task_begin_busy: 0,
writer_task_begin_busy_absorbed: 0,
writer_task_begin_errors: 0,
writer_task_request_failures: 0,
writer_task_side_effects_unknown: 0,
}
);
}
#[test]
fn zero_wait_maintenance_skip_is_not_reported_as_a_checkout_timeout() {
let pool = ConnectionPool::new(PoolConfig::default()).unwrap();
let held = pool.writer().expect("finite-wait checkout succeeds");
let before = pool.writer_acquisition_snapshot();
assert!(
pool.try_writer_nowait().is_err(),
"zero-wait maintenance checkout must skip while held"
);
assert_eq!(
pool.writer_acquisition_snapshot(),
before,
"a checkpoint-style zero-wait skip is not a finite-wait checkout timeout"
);
drop(held);
}
#[test]
#[serial(tx_registry)]
fn writer_guard_transaction_registers_during_closure_only() {
let cfg = PoolConfig {
path: None,
..PoolConfig::default()
};
let pool = ConnectionPool::new(cfg).unwrap();
let guard = pool.writer().unwrap();
let mut seen_during_closure = false;
let result: Result<(), SqliteError> = guard.transaction(|_conn| {
seen_during_closure = khive_storage::tx_registry::snapshot()
.iter()
.any(|(_, label)| label.as_deref() == Some("writer_guard_tx"));
Ok(())
});
result.expect("transaction should commit");
assert!(
seen_during_closure,
"expected a writer_guard_tx entry visible inside the closure"
);
assert!(
!khive_storage::tx_registry::snapshot()
.iter()
.any(|(_, label)| label.as_deref() == Some("writer_guard_tx")),
"expected the entry to be gone after the transaction completes"
);
}
#[test]
fn writer_task_handle_fails_loud_without_tokio_runtime() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("writer_task_no_runtime.db");
let cfg = PoolConfig {
path: Some(path),
write_queue_enabled: Some(true),
..PoolConfig::for_test()
};
let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
let result = pool.writer_task_handle();
assert!(
matches!(result, Err(StorageError::WriterTaskNoRuntime)),
"expected Err(StorageError::WriterTaskNoRuntime) outside a Tokio \
runtime, got {result:?}"
);
assert_eq!(
pool.writer_task_spawn_count(),
0,
"the guard must reject before ever attempting tokio::spawn"
);
}
#[test]
fn strict_writer_task_for_write_preserves_missing_runtime_error() {
let dir = tempfile::tempdir().unwrap();
let pool = ConnectionPool::new(PoolConfig {
path: Some(dir.path().join("strict_writer_task_no_runtime.db")),
write_queue_enabled: Some(true),
write_routing_strict: true,
..PoolConfig::for_test()
})
.expect("file-backed pool should open");
let result = pool.writer_task_for_write(None, "strict_test_write");
assert!(
matches!(result, Err(StorageError::WriterTaskNoRuntime)),
"strict routing must preserve WriterTaskNoRuntime, got {result:?}"
);
assert_eq!(pool.writer_task_spawn_count(), 0);
}
#[tokio::test]
async fn take_writer_task_join_returns_some_once_then_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("join_lifecycle.db");
let pool = ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: Some(true),
..PoolConfig::for_test()
})
.expect("file-backed pool should open");
assert!(
pool.take_writer_task_join().is_none(),
"before spawn there is no JoinHandle to take"
);
assert!(!pool.writer_task_join_was_stored());
pool.writer_task_handle()
.expect("runtime is present")
.expect("write queue enabled must spawn a writer task");
assert!(pool.writer_task_join_was_stored());
let join = pool
.take_writer_task_join()
.expect("the first take must return the spawned task's JoinHandle");
assert!(
pool.take_writer_task_join().is_none(),
"the second take must return None — the handle is one-shot"
);
drop(pool);
tokio::time::timeout(Duration::from_secs(5), join)
.await
.expect("the writer task must exit once every handle clone is dropped")
.expect("the writer task must not panic");
}
#[cfg(debug_assertions)]
#[tokio::test]
#[should_panic(expected = "writer task JoinHandle stored twice")]
async fn set_writer_task_join_second_store_trips_debug_assert() {
let pool = ConnectionPool::new(PoolConfig::default()).expect("in-memory pool should open");
pool.set_writer_task_join(tokio::spawn(async {}));
pool.set_writer_task_join(tokio::spawn(async {}));
}
#[cfg(debug_assertions)]
#[tokio::test]
#[should_panic(expected = "writer task JoinHandle stored twice")]
async fn set_writer_task_join_second_store_after_take_trips_debug_assert() {
let pool = ConnectionPool::new(PoolConfig::default()).expect("in-memory pool should open");
pool.set_writer_task_join(tokio::spawn(async {}));
assert!(pool.take_writer_task_join().is_some());
pool.set_writer_task_join(tokio::spawn(async {}));
}
#[cfg(not(debug_assertions))]
#[tokio::test]
async fn set_writer_task_join_first_wins_keeps_existing_handle() {
let pool = ConnectionPool::new(PoolConfig::default()).expect("in-memory pool should open");
let (first_done_tx, first_done_rx) = tokio::sync::oneshot::channel::<()>();
let first = tokio::spawn(async move {
let _ = first_done_tx.send(());
});
let (_never_sent, never_rx) = tokio::sync::oneshot::channel::<()>();
let second = tokio::spawn(async move {
let _ = never_rx.await;
});
pool.set_writer_task_join(first);
pool.set_writer_task_join(second);
let taken = pool
.take_writer_task_join()
.expect("the first handle must still be stored");
tokio::time::timeout(Duration::from_secs(5), taken)
.await
.expect("stored handle must be the first task's; the second never completes")
.expect("the first task must not panic");
assert!(
first_done_rx.await.is_ok(),
"completing the taken handle must mean the FIRST task ran to completion"
);
}
#[test]
#[serial(pool_cwd)]
fn mint_db_identity_alias_convergence() {
let dir = tempfile::tempdir().unwrap();
let real_dir = dir.path().join("real");
fs::create_dir(&real_dir).unwrap();
let db_path = real_dir.join("khive.db");
fs::write(&db_path, b"").unwrap();
#[cfg(unix)]
let dir_symlink = dir.path().join("dir_link");
#[cfg(unix)]
let file_symlink = dir.path().join("file_link.db");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real_dir, &dir_symlink).unwrap();
std::os::unix::fs::symlink(&db_path, &file_symlink).unwrap();
}
let (via_real, canonical_real) = mint_db_identity(&db_path).unwrap();
let relative_result = {
let _cwd = CwdGuard::enter(&real_dir);
mint_db_identity(&PathBuf::from("khive.db"))
};
let (via_relative, canonical_relative) = relative_result.unwrap();
assert_eq!(canonical_real, canonical_relative);
assert_eq!(via_real, via_relative);
#[cfg(unix)]
{
let (via_dir_symlink, canonical_dir_symlink) =
mint_db_identity(&dir_symlink.join("khive.db")).unwrap();
assert_eq!(canonical_real, canonical_dir_symlink);
assert_eq!(via_real, via_dir_symlink);
let (via_file_symlink, canonical_file_symlink) =
mint_db_identity(&file_symlink).unwrap();
assert_eq!(canonical_real, canonical_file_symlink);
assert_eq!(via_real, via_file_symlink);
}
let bare_name_result = {
let _cwd = CwdGuard::enter(&real_dir);
mint_db_identity(&PathBuf::from("khive.db"))
};
let (via_bare_name, canonical_bare_name) = bare_name_result.unwrap();
assert_eq!(canonical_real, canonical_bare_name);
assert_eq!(via_real, via_bare_name);
}
#[test]
#[serial(pool_cwd)]
fn sidecar_dir_for_alias_convergence() {
let dir = tempfile::tempdir().unwrap();
let real_dir = dir.path().join("real");
fs::create_dir(&real_dir).unwrap();
let db_path = real_dir.join("khive.db");
fs::write(&db_path, b"").unwrap();
#[cfg(unix)]
let dir_symlink = dir.path().join("dir_link");
#[cfg(unix)]
let file_symlink = dir.path().join("file_link.db");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real_dir, &dir_symlink).unwrap();
std::os::unix::fs::symlink(&db_path, &file_symlink).unwrap();
}
let pool_for = |path: &Path| -> Arc<ConnectionPool> {
let cfg = PoolConfig {
path: Some(path.to_path_buf()),
..PoolConfig::for_test()
};
Arc::new(ConnectionPool::new(cfg).expect("file-backed pool should open"))
};
let sidecar_of = |pool: &ConnectionPool| -> PathBuf {
crate::walpin::sidecar_dir_for(pool.canonical_path().expect("file-backed pool"))
};
let via_real = pool_for(&db_path);
let sidecar_real = sidecar_of(&via_real);
let via_relative = {
let _cwd = CwdGuard::enter(&real_dir);
pool_for(Path::new("khive.db"))
};
assert_eq!(
sidecar_real,
sidecar_of(&via_relative),
"a relative spelling of the same database must derive the same sidecar directory"
);
#[cfg(unix)]
{
let via_dir_symlink = pool_for(&dir_symlink.join("khive.db"));
assert_eq!(
sidecar_real,
sidecar_of(&via_dir_symlink),
"opening through a directory symlink must derive the same sidecar directory"
);
let via_file_symlink = pool_for(&file_symlink);
assert_eq!(
sidecar_real,
sidecar_of(&via_file_symlink),
"opening through a file-level symlink must derive the same sidecar directory"
);
}
let via_bare_name = {
let _cwd = CwdGuard::enter(&real_dir);
pool_for(Path::new("khive.db"))
};
assert_eq!(
sidecar_real,
sidecar_of(&via_bare_name),
"a bare file name resolved against the current directory must derive the same \
sidecar directory"
);
}
#[cfg(unix)]
#[test]
fn mint_db_identity_dangling_symlink_first_open_convergence() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.db");
let link = dir.path().join("link.db");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(!target.exists(), "target must not exist yet (dangling)");
let (via_dangling_link, canonical_via_link) = mint_db_identity(&link).unwrap();
fs::write(&target, b"").unwrap();
let (via_target, canonical_via_target) = mint_db_identity(&target).unwrap();
assert_eq!(canonical_via_link, canonical_via_target);
assert_eq!(via_dangling_link, via_target);
}
#[test]
fn mint_db_identity_missing_parent_fails() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nonexistent_subdir").join("khive.db");
let result = mint_db_identity(&missing);
assert!(
result.is_err(),
"minting must fail when the parent directory does not exist"
);
}
#[cfg(unix)]
#[test]
fn mint_db_identity_non_utf8_path_round_trips() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let dir = tempfile::tempdir().unwrap();
let raw_name = OsStr::from_bytes(b"khive-\xffdb.sqlite");
let db_path = dir.path().join(raw_name);
if let Err(e) = fs::write(&db_path, b"") {
eprintln!(
"skipping mint_db_identity_non_utf8_path_round_trips: filesystem rejected a \
non-UTF-8 file name ({e}); this platform's filesystem does not support the \
case under test"
);
return;
}
let (identity, canonical) = mint_db_identity(&db_path).unwrap();
assert_eq!(canonical.file_name().unwrap(), raw_name);
let (identity_again, canonical_again) = mint_db_identity(&db_path).unwrap();
assert_eq!(identity, identity_again);
assert_eq!(canonical, canonical_again);
}
fn admission_identity_after_refusal(pool: &ConnectionPool) -> String {
let held = pool.reader().expect("hold the sole reader");
let Err(error) = pool.resolve_reader_checkout(
StorageCapability::Sql,
"identity_read",
pool.reader_until(|| false),
) else {
panic!("held reader must exhaust this pool's admission budget");
};
assert!(
error.is_retryable(),
"admission refusal must remain retryable"
);
let display = error.to_string();
let StorageError::AdmissionTimeout {
operation,
timeout_ms,
pool_identity,
} = error
else {
panic!("pool refusal must retain its typed admission classification");
};
assert_eq!(
operation, "identity_read",
"pool identity must not alter operation"
);
assert_eq!(timeout_ms, 20);
let identity = pool_identity.expect("typed admission error must name the pool");
assert!(
!identity.contains('/') && !identity.contains('\\'),
"pool identity must never contain a directory or separator: {identity}"
);
assert_eq!(
display,
format!("admission timeout during identity_read after 20ms (pool: {identity})"),
"admission error text must name the refusing pool"
);
drop(held);
identity
}
fn identity_test_pool(path: Option<PathBuf>, read_only: bool) -> ConnectionPool {
ConnectionPool::new(PoolConfig {
path,
read_only,
max_readers: 1,
checkout_timeout: Duration::from_millis(20),
..PoolConfig::default()
})
.unwrap()
}
#[test]
fn reader_admission_timeout_identifies_the_refusing_pool() {
let dir = tempfile::tempdir().unwrap();
for read_only in [false, true] {
let name = format!("identity-{}.db", uuid::Uuid::new_v4());
let path = dir.path().join(&name);
{
let seed = Connection::open(&path).unwrap();
seed.execute_batch("CREATE TABLE seed (id INTEGER)")
.unwrap();
}
let canonical = fs::canonicalize(&path).unwrap();
let configured = dir.path().join(".").join(&name);
assert_ne!(configured.as_os_str(), canonical.as_os_str());
let pool = identity_test_pool(Some(configured), read_only);
assert_eq!(
admission_identity_after_refusal(&pool),
name,
"typed admission field must contain only the canonical file name"
);
#[cfg(unix)]
{
let alias = dir
.path()
.join(format!("alias-{}.db", uuid::Uuid::new_v4()));
std::os::unix::fs::symlink(&canonical, &alias).unwrap();
let alias_pool = identity_test_pool(Some(alias), read_only);
assert_eq!(
admission_identity_after_refusal(&alias_pool),
name,
"symlink spelling must not change the canonical database file name"
);
}
}
let memory = identity_test_pool(None, false);
assert_eq!(admission_identity_after_refusal(&memory), ":memory:");
}
#[test]
fn reader_admission_identity_hash_is_build_stable() {
#[cfg(unix)]
assert_eq!(
pool_identity_suffix(Path::new("/khive/pool/khive.db")),
"8fa8797b",
"suffix must match the published Unix SHA-256 vector"
);
#[cfg(windows)]
assert_eq!(
pool_identity_suffix(Path::new("/khive/pool/khive.db")),
"1186b990",
"suffix must match the published Windows SHA-256 vector"
);
}
fn assert_disambiguated_identity(identity: &str, basename: &str) {
let suffix = identity
.strip_prefix(&format!("{basename}#"))
.expect("different open files with the same basename need a hash suffix");
assert_eq!(
suffix.len(),
8,
"disambiguation needs exactly eight hex digits"
);
assert!(
suffix.bytes().all(|b| b.is_ascii_hexdigit()),
"disambiguation must contain only a hash, never directory text"
);
}
#[test]
fn reader_admission_identity_disambiguates_open_files() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
let basename = format!("collision-{}.db", uuid::Uuid::new_v4());
let first = identity_test_pool(Some(first_dir.path().join(&basename)), false);
assert_eq!(admission_identity_after_refusal(&first), basename);
let second_path = second_dir.path().join(&basename);
let second = identity_test_pool(Some(second_path.clone()), false);
let first_identity = admission_identity_after_refusal(&first);
let second_identity = admission_identity_after_refusal(&second);
assert_disambiguated_identity(&first_identity, &basename);
assert_disambiguated_identity(&second_identity, &basename);
assert_ne!(
first_identity, second_identity,
"distinct files need distinct identities"
);
assert_eq!(admission_identity_after_refusal(&first), first_identity);
drop(second);
assert_eq!(
admission_identity_after_refusal(&first),
basename,
"closing the colliding store must remove its registry entry"
);
let reopened = identity_test_pool(Some(second_path), false);
assert_eq!(admission_identity_after_refusal(&first), first_identity);
assert_eq!(admission_identity_after_refusal(&reopened), second_identity);
}
#[test]
fn reader_admission_identity_same_path_pools_share_label() {
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
let basename = format!("same-path-{}.db", uuid::Uuid::new_v4());
let first = identity_test_pool(Some(first_dir.path().join(&basename)), false);
let duplicate = identity_test_pool(Some(first_dir.path().join(".").join(&basename)), false);
assert_eq!(
admission_identity_after_refusal(&first),
basename,
"two pools on the same canonical path must not get a suffix"
);
assert_eq!(admission_identity_after_refusal(&duplicate), basename);
let other = identity_test_pool(Some(second_dir.path().join(&basename)), false);
let first_identity = admission_identity_after_refusal(&first);
let other_identity = admission_identity_after_refusal(&other);
assert_disambiguated_identity(&first_identity, &basename);
assert_disambiguated_identity(&other_identity, &basename);
assert_eq!(admission_identity_after_refusal(&duplicate), first_identity);
drop(first);
assert_eq!(
admission_identity_after_refusal(&other),
other_identity,
"dropping one pool must retain the other pool's path registration"
);
drop(duplicate);
assert_eq!(
admission_identity_after_refusal(&other),
basename,
"dropping the final pool must remove the path registration"
);
}
#[test]
fn resolve_reader_checkout_maps_each_arm_distinctly() {
let pool = ConnectionPool::new(PoolConfig {
path: None,
..PoolConfig::default()
})
.unwrap();
let guard = pool
.resolve_reader_checkout(
StorageCapability::Sql,
"arm_checked_out",
pool.reader_until(|| false),
)
.expect("an uncontended checkout must pass the guard through");
drop(guard);
let Err(cancelled) =
pool.resolve_reader_checkout(StorageCapability::Sql, "arm_cancelled", Ok(None))
else {
panic!("a cancelled checkout must be refused");
};
assert!(
matches!(cancelled, StorageError::Timeout { .. }),
"cancellation/deadline before checkout must be the non-retryable \
Timeout, got {cancelled:?}"
);
let Err(exhausted) = pool.resolve_reader_checkout(
StorageCapability::Sql,
"arm_exhausted",
Err(pool_exhausted_error(Duration::from_millis(5), 1)),
) else {
panic!("an exhausted checkout must be refused");
};
assert!(
matches!(exhausted, StorageError::AdmissionTimeout { .. }),
"the pool's own SQLITE_BUSY (checkout_timeout exhausted) must be \
the retryable AdmissionTimeout, got {exhausted:?}"
);
let Err(opaque) = pool.resolve_reader_checkout(
StorageCapability::Entities,
"arm_driver",
Err(SqliteError::InvalidData("retired pooled writer".into())),
) else {
panic!("an opaque checkout error must be refused");
};
assert!(
matches!(
&opaque,
StorageError::Driver { capability, .. }
if *capability == StorageCapability::Entities
),
"any other checkout error must stay a non-retryable Driver failure \
under the caller's capability, got {opaque:?}"
);
}
#[test]
fn the_longest_completed_hold_names_the_operation_that_held_it() {
let pool = ConnectionPool::new(PoolConfig {
path: None,
..PoolConfig::default()
})
.unwrap();
for _ in 0..3 {
let guard = pool
.resolve_reader_checkout(
StorageCapability::Sql,
"fast_read",
pool.reader_until(|| false),
)
.expect("a fast checkout resolves");
drop(guard);
}
let slow = pool
.resolve_reader_checkout(
StorageCapability::Sql,
"slow_read",
pool.reader_until(|| false),
)
.expect("the slow checkout resolves");
thread::sleep(Duration::from_millis(20));
drop(slow);
let snapshot = pool.reader_acquisition_snapshot();
assert_eq!(
snapshot.completed_pooled_checkouts, 4,
"all four checkouts must complete through the pooled route, or the \
attribution below is reading a population of one"
);
assert_eq!(
snapshot.max_completed_hold_operation,
Some("slow_read"),
"the longest hold must name the operation that held it; got {:?} at \
{} micros",
snapshot.max_completed_hold_operation,
snapshot.max_completed_hold_micros
);
}
#[test]
fn a_checkout_taken_outside_the_resolve_route_reports_no_operation() {
let pool = ConnectionPool::new(PoolConfig {
path: None,
..PoolConfig::default()
})
.unwrap();
let guard = pool
.reader_until(|| false)
.expect("the checkout succeeds")
.expect("the checkout is not cancelled");
drop(guard);
let snapshot = pool.reader_acquisition_snapshot();
assert_eq!(snapshot.completed_pooled_checkouts, 1);
assert_eq!(
snapshot.max_completed_hold_operation, None,
"an unlabelled route must report no operation rather than borrow one"
);
}
}