1use rusqlite::{
9 params, Connection, ErrorCode, OptionalExtension, Transaction, TransactionBehavior,
10};
11use std::ffi::OsString;
12use std::fmt;
13use std::fs::{File, OpenOptions, TryLockError};
14use std::path::PathBuf;
15use std::sync::Mutex;
16use std::time::Duration;
17
18mod diagnostic;
19pub mod schema;
20pub use diagnostic::{failure_diagnostic, FailureDiagnostic};
21
22const SCHEMA_MARKER_TABLE: &str = "_harn_sqlite_schema_versions";
23const CREATE_SCHEMA_MARKER_TABLE: &str =
24 "CREATE TABLE IF NOT EXISTS main._harn_sqlite_schema_versions (
25 name TEXT PRIMARY KEY,
26 version INTEGER NOT NULL CHECK(version > 0)
27);";
28static TRANSIENT_INITIALIZATION_LOCK: Mutex<()> = Mutex::new(());
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct SchemaVersion {
33 name: &'static str,
34 version: i64,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum SqliteContention {
40 Busy,
42 Locked,
44}
45
46#[must_use]
48pub fn sqlite_contention(error: &rusqlite::Error) -> Option<SqliteContention> {
49 match error {
50 rusqlite::Error::SqliteFailure(failure, _) => match failure.code {
51 ErrorCode::DatabaseBusy => Some(SqliteContention::Busy),
52 ErrorCode::DatabaseLocked => Some(SqliteContention::Locked),
53 _ => None,
54 },
55 _ => None,
56 }
57}
58
59impl SchemaVersion {
60 #[must_use]
64 pub const fn new(name: &'static str, version: i64) -> Self {
65 assert!(!name.is_empty(), "SQLite schema name must not be empty");
66 assert!(version > 0, "SQLite schema version must be positive");
67 Self { name, version }
68 }
69}
70
71#[derive(Debug)]
73#[non_exhaustive]
74pub enum InitializationError<E> {
75 BusyTimeoutTooLarge { milliseconds: u128 },
77 BusyTimeout(rusqlite::Error),
79 JournalModeQuery(rusqlite::Error),
81 DatabasePathUnavailable,
83 FileBackedTransient { path: PathBuf },
85 DatabasePath {
87 path: PathBuf,
88 source: std::io::Error,
89 },
90 InitializationLockOpen {
92 path: PathBuf,
93 source: std::io::Error,
94 },
95 InitializationLockAcquire {
97 path: PathBuf,
98 source: std::io::Error,
99 },
100 InitializationLock(harn_flock::LockError),
103 WalPragma(rusqlite::Error),
105 WalNotEnabled { mode: String },
107 WalBusyNotWal { mode: String },
109 WalBusyQuery {
111 wal_error: Box<rusqlite::Error>,
112 query_error: Box<rusqlite::Error>,
113 },
114 Synchronous(rusqlite::Error),
116 SchemaReadiness(rusqlite::Error),
118 SchemaNotInitialized { name: &'static str, version: i64 },
121 NewerSchemaVersion {
123 name: &'static str,
124 stored: i64,
125 supported: i64,
126 },
127 Transaction(rusqlite::Error),
129 Initialize(E),
131 SchemaMarker(rusqlite::Error),
133 Commit(rusqlite::Error),
135}
136
137impl InitializationError<rusqlite::Error> {
138 #[must_use]
140 pub fn is_busy_or_locked(&self) -> bool {
141 if let Self::Initialize(error) = self {
142 is_sqlite_busy_or_locked(error)
143 } else {
144 initialization_stage_is_busy_or_locked(self)
145 }
146 }
147}
148
149impl<E: fmt::Display> fmt::Display for InitializationError<E> {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 match self {
152 Self::BusyTimeoutTooLarge { milliseconds } => write!(
153 f,
154 "busy_timeout {milliseconds}ms exceeds SQLite's maximum"
155 ),
156 Self::BusyTimeout(error) => write!(f, "busy_timeout failed: {error}"),
157 Self::JournalModeQuery(error) => write!(f, "journal_mode query failed: {error}"),
158 Self::DatabasePathUnavailable => {
159 write!(f, "SQLite connection has no file-backed main database path")
160 }
161 Self::FileBackedTransient { path } => write!(
162 f,
163 "transient SQLite initialization requires a private non-file database, got {}",
164 path.display()
165 ),
166 Self::DatabasePath { path, source } => write!(
167 f,
168 "could not resolve SQLite database path {}: {source}",
169 path.display()
170 ),
171 Self::InitializationLockOpen { path, source } => write!(
172 f,
173 "could not open SQLite initialization lock {}: {source}",
174 path.display()
175 ),
176 Self::InitializationLockAcquire { path, source } => write!(
177 f,
178 "could not acquire SQLite initialization lock {}: {source}",
179 path.display()
180 ),
181 Self::InitializationLock(error) => {
182 write!(f, "SQLite initialization lock unavailable: {error}")
183 }
184 Self::WalPragma(error) => write!(f, "WAL journal_mode pragma failed: {error}"),
185 Self::WalNotEnabled { mode } => {
186 write!(f, "WAL journal_mode request returned {mode}")
187 }
188 Self::WalBusyNotWal { mode } => {
189 write!(f, "WAL journal_mode request left journal_mode at {mode}")
190 }
191 Self::WalBusyQuery {
192 wal_error,
193 query_error,
194 } => write!(
195 f,
196 "WAL journal_mode pragma failed: {wal_error}; journal_mode query also failed: {query_error}"
197 ),
198 Self::Synchronous(error) => write!(f, "synchronous pragma failed: {error}"),
199 Self::SchemaReadiness(error) => write!(f, "schema readiness query failed: {error}"),
200 Self::SchemaNotInitialized { name, version } => {
201 write!(f, "SQLite schema {name} version {version} is not initialized")
202 }
203 Self::NewerSchemaVersion {
204 name,
205 stored,
206 supported,
207 } => write!(
208 f,
209 "SQLite schema {name} version {stored} is newer than supported version {supported}"
210 ),
211 Self::Transaction(error) => write!(f, "schema transaction failed: {error}"),
212 Self::Initialize(error) => write!(f, "schema initialization failed: {error}"),
213 Self::SchemaMarker(error) => write!(f, "schema marker update failed: {error}"),
214 Self::Commit(error) => write!(f, "schema transaction commit failed: {error}"),
215 }
216 }
217}
218
219impl<E: std::error::Error + 'static> std::error::Error for InitializationError<E> {
220 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
221 match self {
222 Self::BusyTimeout(error)
223 | Self::JournalModeQuery(error)
224 | Self::WalPragma(error)
225 | Self::Synchronous(error)
226 | Self::SchemaReadiness(error)
227 | Self::Transaction(error)
228 | Self::SchemaMarker(error)
229 | Self::Commit(error) => Some(error),
230 Self::DatabasePath { source, .. }
231 | Self::InitializationLockOpen { source, .. }
232 | Self::InitializationLockAcquire { source, .. } => Some(source),
233 Self::InitializationLock(error) => Some(error),
234 Self::WalBusyQuery { wal_error, .. } => Some(wal_error),
235 Self::Initialize(error) => Some(error),
236 Self::BusyTimeoutTooLarge { .. }
237 | Self::DatabasePathUnavailable
238 | Self::FileBackedTransient { .. }
239 | Self::SchemaNotInitialized { .. }
240 | Self::WalNotEnabled { .. }
241 | Self::WalBusyNotWal { .. }
242 | Self::NewerSchemaVersion { .. } => None,
243 }
244 }
245}
246
247impl<E> InitializationError<E> {
248 #[must_use]
250 pub fn stage(&self) -> &'static str {
251 match self {
252 Self::BusyTimeoutTooLarge { .. } | Self::BusyTimeout(_) => "busy_timeout",
253 Self::JournalModeQuery(_) => "journal_mode_query",
254 Self::DatabasePathUnavailable
255 | Self::DatabasePath { .. }
256 | Self::FileBackedTransient { .. } => "database_path",
257 Self::InitializationLockOpen { .. } => "initialization_lock_open",
258 Self::InitializationLockAcquire { .. } | Self::InitializationLock(_) => {
259 "initialization_lock_acquire"
260 }
261 Self::WalPragma(_)
262 | Self::WalNotEnabled { .. }
263 | Self::WalBusyNotWal { .. }
264 | Self::WalBusyQuery { .. } => "wal_promotion",
265 Self::Synchronous(_) => "synchronous",
266 Self::SchemaReadiness(_)
267 | Self::SchemaNotInitialized { .. }
268 | Self::NewerSchemaVersion { .. } => "schema_readiness",
269 Self::Transaction(_) => "schema_transaction",
270 Self::Initialize(_) => "schema_initialize",
271 Self::SchemaMarker(_) => "schema_marker",
272 Self::Commit(_) => "schema_commit",
273 }
274 }
275}
276
277pub fn initialize_file<E, F>(
288 connection: &Connection,
289 busy_timeout: Duration,
290 schema: SchemaVersion,
291 initialize: F,
292) -> Result<(), InitializationError<E>>
293where
294 F: FnOnce(&Transaction<'_>) -> Result<(), E>,
295{
296 configure_busy_timeout(connection, busy_timeout)?;
297 if fast_path_is_ready(connection, schema)? {
298 return configure_connection(connection);
299 }
300
301 let _initialization_lock = acquire_initialization_lock(connection, lock_timeout())?;
302 ensure_wal_journal_mode(connection)?;
303 configure_connection(connection)?;
304 initialize_schema(connection, schema, initialize)
305}
306
307fn fast_path_is_ready<E>(
308 connection: &Connection,
309 schema: SchemaVersion,
310) -> Result<bool, InitializationError<E>> {
311 match is_wal_journal_mode(connection) {
312 Ok(true) => {}
313 Ok(false) => return Ok(false),
314 Err(error) if initialization_stage_is_busy_or_locked(&error) => return Ok(false),
315 Err(error) => return Err(error),
316 }
317 match schema_is_ready(connection, schema) {
318 Ok(ready) => Ok(ready),
319 Err(error) if initialization_stage_is_busy_or_locked(&error) => Ok(false),
320 Err(error) => Err(error),
321 }
322}
323
324fn initialization_stage_is_busy_or_locked<E>(error: &InitializationError<E>) -> bool {
325 match error {
326 InitializationError::BusyTimeout(error)
327 | InitializationError::JournalModeQuery(error)
328 | InitializationError::WalPragma(error)
329 | InitializationError::Synchronous(error)
330 | InitializationError::SchemaReadiness(error)
331 | InitializationError::Transaction(error)
332 | InitializationError::SchemaMarker(error)
333 | InitializationError::Commit(error) => is_sqlite_busy_or_locked(error),
334 InitializationError::WalBusyNotWal { .. } => true,
335 InitializationError::WalBusyQuery {
336 wal_error,
337 query_error,
338 } => is_sqlite_busy_or_locked(wal_error) || is_sqlite_busy_or_locked(query_error),
339 InitializationError::BusyTimeoutTooLarge { .. }
340 | InitializationError::DatabasePath { .. }
341 | InitializationError::DatabasePathUnavailable
342 | InitializationError::FileBackedTransient { .. }
343 | InitializationError::InitializationLockOpen { .. }
344 | InitializationError::InitializationLockAcquire { .. }
345 | InitializationError::InitializationLock(_)
346 | InitializationError::SchemaNotInitialized { .. }
347 | InitializationError::NewerSchemaVersion { .. }
348 | InitializationError::WalNotEnabled { .. }
349 | InitializationError::Initialize(_) => false,
350 }
351}
352
353pub fn require_file_initialized<E>(
362 connection: &Connection,
363 busy_timeout: Duration,
364 schema: SchemaVersion,
365) -> Result<(), InitializationError<E>> {
366 require_file_initialized_impl(connection, busy_timeout, schema, || {})
367}
368
369pub fn require_snapshot_initialized<E>(
378 connection: &Connection,
379 schema: SchemaVersion,
380) -> Result<(), InitializationError<E>> {
381 if schema_is_ready(connection, schema)? {
382 Ok(())
383 } else {
384 Err(InitializationError::SchemaNotInitialized {
385 name: schema.name,
386 version: schema.version,
387 })
388 }
389}
390
391fn require_file_initialized_impl<E>(
392 connection: &Connection,
393 busy_timeout: Duration,
394 schema: SchemaVersion,
395 on_readiness_contention: impl FnOnce(),
396) -> Result<(), InitializationError<E>> {
397 configure_busy_timeout(connection, busy_timeout)?;
398 if fast_path_is_ready(connection, schema)? {
399 return Ok(());
400 }
401
402 let _readiness_lock =
403 acquire_readiness_lock(connection, schema, lock_timeout(), on_readiness_contention)?;
404 if is_wal_journal_mode(connection)? && schema_is_ready(connection, schema)? {
405 return Ok(());
406 }
407 Err(InitializationError::SchemaNotInitialized {
408 name: schema.name,
409 version: schema.version,
410 })
411}
412
413pub fn initialize_transient<E, F>(
422 connection: &Connection,
423 busy_timeout: Duration,
424 schema: SchemaVersion,
425 initialize: F,
426) -> Result<(), InitializationError<E>>
427where
428 F: FnOnce(&Transaction<'_>) -> Result<(), E>,
429{
430 configure_busy_timeout(connection, busy_timeout)?;
431 if let Some(path) = main_database_path(connection) {
432 return Err(InitializationError::FileBackedTransient { path });
433 }
434 let _initialization_lock = TRANSIENT_INITIALIZATION_LOCK
435 .lock()
436 .unwrap_or_else(std::sync::PoisonError::into_inner);
437 configure_connection(connection)?;
438 if schema_is_ready(connection, schema)? {
439 return Ok(());
440 }
441 initialize_schema(connection, schema, initialize)
442}
443
444fn initialize_schema<E, F>(
445 connection: &Connection,
446 schema: SchemaVersion,
447 initialize: F,
448) -> Result<(), InitializationError<E>>
449where
450 F: FnOnce(&Transaction<'_>) -> Result<(), E>,
451{
452 let transaction = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)
453 .map_err(InitializationError::Transaction)?;
454 transaction
455 .execute_batch(CREATE_SCHEMA_MARKER_TABLE)
456 .map_err(InitializationError::SchemaMarker)?;
457 if schema_marker_is_ready(&transaction, schema)? {
458 return transaction.commit().map_err(InitializationError::Commit);
459 }
460 initialize(&transaction).map_err(InitializationError::Initialize)?;
461 transaction
462 .execute(
463 "INSERT INTO main._harn_sqlite_schema_versions(name, version) VALUES (?1, ?2)
464 ON CONFLICT(name) DO UPDATE SET version = excluded.version",
465 params![schema.name, schema.version],
466 )
467 .map_err(InitializationError::SchemaMarker)?;
468 transaction.commit().map_err(InitializationError::Commit)
469}
470
471fn configure_busy_timeout<E>(
472 connection: &Connection,
473 busy_timeout: Duration,
474) -> Result<(), InitializationError<E>> {
475 let milliseconds = busy_timeout.as_millis();
476 if milliseconds > i32::MAX as u128 {
477 return Err(InitializationError::BusyTimeoutTooLarge { milliseconds });
478 }
479 connection
480 .busy_timeout(busy_timeout)
481 .map_err(InitializationError::BusyTimeout)
482}
483
484fn configure_connection<E>(connection: &Connection) -> Result<(), InitializationError<E>> {
485 connection
486 .pragma_update(None, "synchronous", "NORMAL")
487 .map_err(InitializationError::Synchronous)
488}
489
490pub const LOCK_TIMEOUT_SECONDS_ENV: &str = "HARN_SQLITE_LOCK_TIMEOUT_SECONDS";
492
493const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
500
501fn lock_timeout() -> Duration {
508 std::env::var(LOCK_TIMEOUT_SECONDS_ENV)
509 .ok()
510 .and_then(|value| value.trim().parse::<u64>().ok())
511 .filter(|seconds| *seconds > 0)
512 .map_or(DEFAULT_LOCK_TIMEOUT, Duration::from_secs)
513}
514
515fn acquire_initialization_lock<E>(
516 connection: &Connection,
517 timeout: Duration,
518) -> Result<SqliteInitializationLock, InitializationError<E>> {
519 let path = initialization_lock_path(connection)?;
520 let file = OpenOptions::new()
521 .create(true)
522 .truncate(false)
523 .read(true)
524 .write(true)
525 .open(&path)
526 .map_err(|source| InitializationError::InitializationLockOpen {
527 path: path.clone(),
528 source,
529 })?;
530 harn_flock::lock_with_deadline(&file, &path, harn_flock::LockMode::Exclusive, timeout)
531 .map_err(InitializationError::InitializationLock)?;
532 Ok(SqliteInitializationLock { file })
533}
534
535fn acquire_readiness_lock<E>(
536 connection: &Connection,
537 schema: SchemaVersion,
538 timeout: Duration,
539 on_contention: impl FnOnce(),
540) -> Result<SqliteInitializationLock, InitializationError<E>> {
541 let path = initialization_lock_path(connection)?;
542 let file = match OpenOptions::new().read(true).open(&path) {
543 Ok(file) => file,
544 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
545 return Err(InitializationError::SchemaNotInitialized {
546 name: schema.name,
547 version: schema.version,
548 });
549 }
550 Err(source) => {
551 return Err(InitializationError::InitializationLockOpen { path, source });
552 }
553 };
554 match file.try_lock_shared() {
555 Ok(()) => {}
556 Err(TryLockError::WouldBlock) => {
557 on_contention();
558 harn_flock::lock_with_deadline(&file, &path, harn_flock::LockMode::Shared, timeout)
559 .map_err(InitializationError::InitializationLock)?;
560 }
561 Err(TryLockError::Error(source)) => {
562 return Err(InitializationError::InitializationLockAcquire { path, source });
563 }
564 }
565 Ok(SqliteInitializationLock { file })
566}
567
568fn initialization_lock_path<E>(connection: &Connection) -> Result<PathBuf, InitializationError<E>> {
569 let database_path =
570 main_database_path(connection).ok_or(InitializationError::DatabasePathUnavailable)?;
571 let canonical = std::fs::canonicalize(&database_path).map_err(|source| {
572 InitializationError::DatabasePath {
573 path: database_path.clone(),
574 source,
575 }
576 })?;
577 let mut path = OsString::from(canonical.as_os_str());
578 path.push(".harn-init.lock");
579 Ok(PathBuf::from(path))
580}
581
582#[cfg(unix)]
583fn main_database_path(connection: &Connection) -> Option<PathBuf> {
584 use std::ffi::{CStr, OsStr};
585 use std::os::unix::ffi::OsStrExt;
586
587 let filename = unsafe {
590 let pointer =
591 rusqlite::ffi::sqlite3_db_filename(connection.handle(), rusqlite::MAIN_DB.as_ptr());
592 (!pointer.is_null()).then(|| CStr::from_ptr(pointer).to_bytes())
593 }?;
594 (!filename.is_empty()).then(|| PathBuf::from(OsStr::from_bytes(filename)))
595}
596
597#[cfg(not(unix))]
598fn main_database_path(connection: &Connection) -> Option<PathBuf> {
599 connection
600 .path()
601 .filter(|path| !path.is_empty())
602 .map(PathBuf::from)
603}
604
605struct SqliteInitializationLock {
606 file: File,
607}
608
609impl Drop for SqliteInitializationLock {
610 fn drop(&mut self) {
611 let _ = self.file.unlock();
614 }
615}
616
617fn schema_is_ready<E>(
618 connection: &Connection,
619 schema: SchemaVersion,
620) -> Result<bool, InitializationError<E>> {
621 let marker_exists = connection
622 .query_row(
623 "SELECT EXISTS(
624 SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = ?1
625 )",
626 params![SCHEMA_MARKER_TABLE],
627 |row| row.get::<_, bool>(0),
628 )
629 .map_err(InitializationError::SchemaReadiness)?;
630 if !marker_exists {
631 return Ok(false);
632 }
633 schema_marker_is_ready(connection, schema)
634}
635
636fn schema_marker_is_ready<E>(
637 connection: &Connection,
638 schema: SchemaVersion,
639) -> Result<bool, InitializationError<E>> {
640 let stored = connection
641 .query_row(
642 "SELECT version FROM main._harn_sqlite_schema_versions WHERE name = ?1",
643 params![schema.name],
644 |row| row.get::<_, i64>(0),
645 )
646 .optional()
647 .map_err(InitializationError::SchemaReadiness)?;
648 match stored {
649 Some(version) if version > schema.version => Err(InitializationError::NewerSchemaVersion {
650 name: schema.name,
651 stored: version,
652 supported: schema.version,
653 }),
654 Some(version) => Ok(version == schema.version),
655 None => Ok(false),
656 }
657}
658
659fn is_wal_journal_mode<E>(connection: &Connection) -> Result<bool, InitializationError<E>> {
660 current_journal_mode(connection)
661 .map(|mode| mode.eq_ignore_ascii_case("wal"))
662 .map_err(InitializationError::JournalModeQuery)
663}
664
665fn ensure_wal_journal_mode<E>(connection: &Connection) -> Result<(), InitializationError<E>> {
666 if is_wal_journal_mode(connection)? {
667 return Ok(());
668 }
669 match connection.query_row("PRAGMA journal_mode = WAL", [], |row| {
670 row.get::<_, String>(0)
671 }) {
672 Ok(mode) if mode.eq_ignore_ascii_case("wal") => Ok(()),
673 Ok(mode) => Err(InitializationError::WalNotEnabled { mode }),
674 Err(error) if is_sqlite_busy_or_locked(&error) => match current_journal_mode(connection) {
675 Ok(mode) if mode.eq_ignore_ascii_case("wal") => Ok(()),
676 Ok(mode) => Err(InitializationError::WalBusyNotWal { mode }),
677 Err(query_error) => Err(InitializationError::WalBusyQuery {
678 wal_error: Box::new(error),
679 query_error: Box::new(query_error),
680 }),
681 },
682 Err(error) => Err(InitializationError::WalPragma(error)),
683 }
684}
685
686fn current_journal_mode(connection: &Connection) -> Result<String, rusqlite::Error> {
687 connection.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
688}
689
690fn is_sqlite_busy_or_locked(error: &rusqlite::Error) -> bool {
691 sqlite_contention(error).is_some()
692}
693
694#[cfg(test)]
695#[path = "tests.rs"]
696mod tests;