Skip to main content

basalt/
database.rs

1//! Public database, connection, transaction, and recovery API.
2//!
3//! `State` remains available for small embedded/in-memory executor tests, but
4//! applications should use [`Database`].  A transaction works on a private
5//! snapshot and publishes it with an optimistic generation check.  Readers
6//! therefore never observe half of a write, and concurrent writers fail with a
7//! transaction conflict instead of silently losing updates.
8
9use std::fs::{self, File, OpenOptions};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, Mutex, RwLock};
13
14use crate::db::{DbError, DbErrorKind, State, StatementResult, dberr};
15use crate::sql::ast::Statement;
16use crate::sql::parser::parse;
17use crate::{storage, wal};
18
19struct Inner {
20    path: Option<PathBuf>,
21    wal_path: Option<PathBuf>,
22    _lock_file: Option<File>,
23    _workspace_lock_file: Option<File>,
24    state: RwLock<State>,
25    generation: AtomicU64,
26    commit_lock: Mutex<()>,
27}
28
29/// A cloneable handle to an embedded Basalt database.
30#[derive(Clone)]
31pub struct Database {
32    inner: Arc<Inner>,
33}
34
35impl Database {
36    /// Create an empty, non-durable database.
37    pub fn in_memory() -> Database {
38        Database {
39            inner: Arc::new(Inner {
40                path: None,
41                wal_path: None,
42                _lock_file: None,
43                _workspace_lock_file: None,
44                state: RwLock::new(State::empty()),
45                generation: AtomicU64::new(0),
46                commit_lock: Mutex::new(()),
47            }),
48        }
49    }
50
51    /// Open or create a durable database at `path`.
52    pub fn open(path: impl AsRef<Path>) -> Result<Database, DbError> {
53        Self::open_internal(path, false)
54    }
55
56    /// Open a workspace database when the caller already owns its workspace
57    /// lock. The returned handle still owns the database lock as usual.
58    pub(crate) fn open_in_workspace(path: impl AsRef<Path>) -> Result<Database, DbError> {
59        Self::open_internal(path, true)
60    }
61
62    fn open_internal(
63        path: impl AsRef<Path>,
64        workspace_lock_already_held: bool,
65    ) -> Result<Database, DbError> {
66        let path = path.as_ref().to_path_buf();
67        reject_symlink(&path, "database path")?;
68        let workspace_lock_file = if workspace_lock_already_held {
69            None
70        } else {
71            acquire_workspace_lock(&path)?
72        };
73        let lock_file = acquire_lock(&path)?;
74        let wal_path = wal_path(&path);
75        let frame = wal::latest(&wal_path)?;
76        let snapshot = storage::read_snapshot(&path);
77        let (mut state, mut generation, mut repair_snapshot) = match snapshot {
78            Ok((state, generation)) => (state, generation, false),
79            Err(error) => {
80                let Some(frame) = &frame else {
81                    return Err(error);
82                };
83                let snapshot_generation = storage::read_snapshot_generation(&path)?;
84                if let Some(snapshot_generation) = snapshot_generation
85                    && frame.generation <= snapshot_generation
86                {
87                    return Err(dberr(
88                        DbErrorKind::Io("WAL is not newer than the damaged snapshot".into()),
89                        format!(
90                            "corrupt database snapshot cannot be safely recovered: WAL generation {} is not newer than snapshot generation {}",
91                            frame.generation, snapshot_generation
92                        ),
93                    ));
94                }
95                (State::decode(&frame.payload)?, frame.generation, true)
96            }
97        };
98        if let Some(frame) = frame {
99            if frame.generation > generation {
100                state = State::decode(&frame.payload)?;
101                generation = frame.generation;
102                // Complete recovery before exposing the handle.  If this
103                // process is killed again, the valid WAL frame remains.
104                storage::write_snapshot(&path, &state, generation)?;
105                wal::truncate(&wal_path)?;
106                repair_snapshot = false;
107            } else {
108                if frame.generation == generation {
109                    let wal_state = State::decode(&frame.payload)?;
110                    if wal_state.encode() != state.encode() {
111                        return Err(dberr(
112                            DbErrorKind::Io("same-generation WAL and snapshot differ".into()),
113                            "corrupt database: same-generation WAL and snapshot differ",
114                        ));
115                    }
116                }
117                // The snapshot is at least as new as every WAL frame; a
118                // previous checkpoint may have been interrupted after the
119                // snapshot install and left stale frames behind.
120                if repair_snapshot {
121                    storage::write_snapshot(&path, &state, generation)?;
122                    repair_snapshot = false;
123                }
124                wal::truncate(&wal_path)?;
125            }
126        }
127        if repair_snapshot || !path.exists() {
128            storage::write_snapshot(&path, &state, generation)?;
129        }
130        Ok(Database {
131            inner: Arc::new(Inner {
132                path: Some(path),
133                wal_path: Some(wal_path),
134                _lock_file: Some(lock_file),
135                _workspace_lock_file: workspace_lock_file,
136                state: RwLock::new(state),
137                generation: AtomicU64::new(generation),
138                commit_lock: Mutex::new(()),
139            }),
140        })
141    }
142
143    /// Begin an optimistic snapshot transaction.
144    pub fn begin(&self) -> Result<Transaction, DbError> {
145        let mut budget = crate::engine::ExecutionBudget::unlimited();
146        self.begin_with_budget(&mut budget)
147    }
148
149    pub(crate) fn begin_with_budget(
150        &self,
151        budget: &mut crate::engine::ExecutionBudget,
152    ) -> Result<Transaction, DbError> {
153        let state_guard = self
154            .inner
155            .state
156            .read()
157            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
158        budget.state_clone(&state_guard, "starting a database snapshot")?;
159        // The generation is published while the write lock is held. Reading
160        // it under the same read guard keeps the cloned state and its
161        // snapshot number from crossing a concurrent commit.
162        let state = state_guard.clone();
163        let generation = self.inner.generation.load(Ordering::Acquire);
164        Ok(Transaction {
165            db: self.clone(),
166            state,
167            base_generation: generation,
168            active: true,
169            dirty: false,
170        })
171    }
172
173    /// Alias for [`Database::begin`] using the conventional transaction name.
174    pub fn transaction(&self) -> Result<Transaction, DbError> {
175        self.begin()
176    }
177
178    /// Create a stateful connection.  Connections are useful when SQL
179    /// `BEGIN`, `COMMIT`, and `ROLLBACK` statements span multiple calls.
180    pub fn connect(&self) -> Connection {
181        Connection {
182            db: self.clone(),
183            transaction: None,
184        }
185    }
186
187    /// Execute one statement as an autocommit operation.
188    pub fn execute(&self, stmt: &Statement) -> Result<StatementResult, DbError> {
189        let mut budget = crate::engine::ExecutionBudget::unlimited();
190        self.execute_with_budget(stmt, &mut budget)
191    }
192
193    pub(crate) fn execute_with_budget(
194        &self,
195        stmt: &Statement,
196        budget: &mut crate::engine::ExecutionBudget,
197    ) -> Result<StatementResult, DbError> {
198        match stmt {
199            Statement::Checkpoint => {
200                self.checkpoint_with_budget(budget)?;
201                Ok(StatementResult::Checkpoint)
202            }
203            Statement::Begin => Ok(StatementResult::Begin),
204            Statement::Commit => Ok(StatementResult::Commit),
205            Statement::Rollback => Ok(StatementResult::Rollback),
206            _ => {
207                let mut transaction = self.begin_with_budget(budget)?;
208                let result = transaction.execute_with_budget(stmt, budget)?;
209                if is_mutation(&result) {
210                    transaction.commit_with_budget(budget)?;
211                } else {
212                    transaction.rollback();
213                }
214                Ok(result)
215            }
216        }
217    }
218
219    /// Parse and execute all statements through a fresh autocommit connection.
220    pub fn execute_sql(&self, sql: &str) -> Result<Vec<StatementResult>, DbError> {
221        self.connect().execute_sql(sql)
222    }
223
224    pub(crate) fn execute_sql_with_budget(
225        &self,
226        sql: &str,
227        max_work: usize,
228    ) -> Result<Vec<StatementResult>, DbError> {
229        self.connect().execute_sql_with_budget(sql, max_work)
230    }
231
232    /// Flush the current state into the page file and clear committed WAL
233    /// frames.  Checkpointing is safe while readers are active.
234    pub fn checkpoint(&self) -> Result<(), DbError> {
235        let mut budget = crate::engine::ExecutionBudget::unlimited();
236        self.checkpoint_with_budget(&mut budget)
237    }
238
239    pub(crate) fn checkpoint_with_budget(
240        &self,
241        budget: &mut crate::engine::ExecutionBudget,
242    ) -> Result<(), DbError> {
243        let _commit = self
244            .inner
245            .commit_lock
246            .lock()
247            .map_err(|_| dberr(DbErrorKind::Transaction, "database commit lock poisoned"))?;
248        let Some(path) = &self.inner.path else {
249            return Ok(());
250        };
251        let state = self
252            .inner
253            .state
254            .read()
255            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
256        budget.state_clone(&state, "preparing a database checkpoint")?;
257        let state = state.clone();
258        let generation = self.inner.generation.load(Ordering::Acquire);
259        storage::write_snapshot(path, &state, generation)?;
260        if let Some(wal_path) = &self.inner.wal_path {
261            wal::truncate(wal_path)?;
262        }
263        Ok(())
264    }
265
266    pub(crate) fn restore_snapshot_with_budget(
267        &self,
268        snapshot_path: &Path,
269        budget: &mut crate::engine::ExecutionBudget,
270    ) -> Result<u64, DbError> {
271        let metadata = fs::symlink_metadata(snapshot_path).map_err(|error| {
272            dberr(
273                DbErrorKind::Io(format!("inspect recovery snapshot: {error}")),
274                format!("inspect recovery snapshot: {error}"),
275            )
276        })?;
277        if metadata.file_type().is_symlink() {
278            return Err(dberr(
279                DbErrorKind::Io("recovery snapshot cannot be a symbolic link".into()),
280                "recovery snapshot cannot be a symbolic link",
281            ));
282        }
283        if !metadata.is_file() {
284            return Err(dberr(
285                DbErrorKind::Io("recovery snapshot is not a regular file".into()),
286                "recovery snapshot is not a regular file",
287            ));
288        }
289        let (state, snapshot_generation) = storage::read_snapshot(snapshot_path)?;
290        let current_generation = self.generation();
291        if snapshot_generation > current_generation {
292            return Err(dberr(
293                DbErrorKind::Transaction,
294                format!(
295                    "recovery snapshot generation {snapshot_generation} is newer than database generation {current_generation}"
296                ),
297            ));
298        }
299        budget.state_clone(&state, "preparing a database restore")?;
300        self.commit_state(state, current_generation)
301    }
302
303    /// Current committed generation, useful for diagnostics and tests.
304    pub fn generation(&self) -> u64 {
305        self.inner.generation.load(Ordering::Acquire)
306    }
307
308    /// Return table names in deterministic order for schema discovery tools.
309    pub fn table_names(&self) -> Result<Vec<String>, DbError> {
310        let state = self
311            .inner
312            .state
313            .read()
314            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
315        let mut names: Vec<String> = state.tables.keys().cloned().collect();
316        names.sort_by_key(|name| name.to_ascii_lowercase());
317        Ok(names)
318    }
319
320    /// Return a table's column metadata for migrations and introspection.
321    pub fn columns(&self, table: &str) -> Result<Vec<crate::db::Column>, DbError> {
322        let state = self
323            .inner
324            .state
325            .read()
326            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
327        state
328            .table(table)
329            .map(|value| value.columns.clone())
330            .ok_or_else(|| dberr(DbErrorKind::UnknownTable, format!("no such table: {table}")))
331    }
332
333    /// Return the number of live rows in a table without materializing them.
334    pub fn row_count(&self, table: &str) -> Result<usize, DbError> {
335        let state = self
336            .inner
337            .state
338            .read()
339            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
340        state
341            .table(table)
342            .map(crate::db::Table::row_count)
343            .ok_or_else(|| dberr(DbErrorKind::UnknownTable, format!("no such table: {table}")))
344    }
345
346    fn commit_state(&self, state: State, expected: u64) -> Result<u64, DbError> {
347        let _commit = self
348            .inner
349            .commit_lock
350            .lock()
351            .map_err(|_| dberr(DbErrorKind::Transaction, "database commit lock poisoned"))?;
352        let mut current = self
353            .inner
354            .state
355            .write()
356            .map_err(|_| dberr(DbErrorKind::Transaction, "database state lock poisoned"))?;
357        let actual = self.inner.generation.load(Ordering::Acquire);
358        if actual != expected {
359            return Err(dberr(
360                DbErrorKind::Transaction,
361                format!("transaction conflict: snapshot {expected}, database is at {actual}"),
362            ));
363        }
364        let generation = actual
365            .checked_add(1)
366            .ok_or_else(|| dberr(DbErrorKind::Transaction, "transaction generation exhausted"))?;
367        let payload = state.encode();
368        if payload.len() > storage::MAX_SNAPSHOT_PAYLOAD_BYTES {
369            return Err(dberr(
370                DbErrorKind::Limit,
371                "database state is too large for the configured snapshot limit",
372            ));
373        }
374        if let Some(wal_path) = &self.inner.wal_path {
375            wal::append(wal_path, generation, &payload)?;
376        }
377        *current = state;
378        self.inner.generation.store(generation, Ordering::Release);
379        Ok(generation)
380    }
381}
382
383/// A connection that can hold one transaction across multiple statements.
384pub struct Connection {
385    db: Database,
386    transaction: Option<Transaction>,
387}
388
389impl Connection {
390    pub fn execute(&mut self, stmt: &Statement) -> Result<StatementResult, DbError> {
391        let mut budget = crate::engine::ExecutionBudget::unlimited();
392        self.execute_with_budget(stmt, &mut budget)
393    }
394
395    pub(crate) fn execute_with_budget(
396        &mut self,
397        stmt: &Statement,
398        budget: &mut crate::engine::ExecutionBudget,
399    ) -> Result<StatementResult, DbError> {
400        match stmt {
401            Statement::Checkpoint => {
402                if self.transaction.is_some() {
403                    return Err(dberr(
404                        DbErrorKind::Transaction,
405                        "cannot checkpoint while a transaction is active",
406                    ));
407                }
408                self.db.checkpoint_with_budget(budget)?;
409                Ok(StatementResult::Checkpoint)
410            }
411            Statement::Begin => {
412                if self.transaction.is_some() {
413                    return Err(dberr(
414                        DbErrorKind::Transaction,
415                        "transaction already active",
416                    ));
417                }
418                self.transaction = Some(self.db.begin_with_budget(budget)?);
419                Ok(StatementResult::Begin)
420            }
421            Statement::Commit => {
422                let Some(transaction) = self.transaction.take() else {
423                    return Err(dberr(DbErrorKind::Transaction, "no transaction is active"));
424                };
425                transaction.commit_with_budget(budget)?;
426                Ok(StatementResult::Commit)
427            }
428            Statement::Rollback => {
429                if let Some(transaction) = self.transaction.take() {
430                    transaction.rollback();
431                }
432                Ok(StatementResult::Rollback)
433            }
434            _ => match self.transaction.as_mut() {
435                Some(transaction) => transaction.execute_with_budget(stmt, budget),
436                None => self.db.execute_with_budget(stmt, budget),
437            },
438        }
439    }
440
441    pub fn execute_sql(&mut self, sql: &str) -> Result<Vec<StatementResult>, DbError> {
442        let mut budget = crate::engine::ExecutionBudget::unlimited();
443        self.execute_sql_using_budget(sql, &mut budget)
444    }
445
446    pub(crate) fn execute_sql_with_budget(
447        &mut self,
448        sql: &str,
449        max_work: usize,
450    ) -> Result<Vec<StatementResult>, DbError> {
451        let mut budget = crate::engine::ExecutionBudget::bounded(max_work);
452        self.execute_sql_using_budget(sql, &mut budget)
453    }
454
455    pub(crate) fn execute_sql_using_budget(
456        &mut self,
457        sql: &str,
458        budget: &mut crate::engine::ExecutionBudget,
459    ) -> Result<Vec<StatementResult>, DbError> {
460        let statements = parse(sql).map_err(|e| {
461            dberr(
462                DbErrorKind::Syntax(e.message.clone()),
463                format!("{} at byte {}", e.message, e.offset),
464            )
465        })?;
466        let mut results = Vec::with_capacity(statements.len());
467        for statement in statements {
468            results.push(self.execute_with_budget(&statement, budget)?);
469        }
470        Ok(results)
471    }
472
473    pub fn in_transaction(&self) -> bool {
474        self.transaction.is_some()
475    }
476
477    /// Return the committed generation visible to this connection's database.
478    pub fn generation(&self) -> u64 {
479        self.db.generation()
480    }
481}
482
483/// A private MVCC-style snapshot.  Reads use the snapshot without holding a
484/// database lock; commit publishes it only if no newer generation exists.
485pub struct Transaction {
486    db: Database,
487    state: State,
488    base_generation: u64,
489    active: bool,
490    dirty: bool,
491}
492
493impl Transaction {
494    pub fn execute(&mut self, stmt: &Statement) -> Result<StatementResult, DbError> {
495        let mut budget = crate::engine::ExecutionBudget::unlimited();
496        self.execute_with_budget(stmt, &mut budget)
497    }
498
499    pub(crate) fn execute_with_budget(
500        &mut self,
501        stmt: &Statement,
502        budget: &mut crate::engine::ExecutionBudget,
503    ) -> Result<StatementResult, DbError> {
504        if !self.active {
505            return Err(dberr(DbErrorKind::Transaction, "transaction is closed"));
506        }
507        match stmt {
508            Statement::Begin | Statement::Commit | Statement::Rollback | Statement::Checkpoint => {
509                Err(dberr(
510                    DbErrorKind::Transaction,
511                    "transaction control is owned by the connection",
512                ))
513            }
514            _ => {
515                let result = crate::engine::execute_with_budget(&mut self.state, stmt, budget)?;
516                if is_mutation(&result) {
517                    self.dirty = true;
518                }
519                Ok(result)
520            }
521        }
522    }
523
524    pub fn execute_sql(&mut self, sql: &str) -> Result<Vec<StatementResult>, DbError> {
525        let mut budget = crate::engine::ExecutionBudget::unlimited();
526        self.execute_sql_using_budget(sql, &mut budget)
527    }
528
529    fn execute_sql_using_budget(
530        &mut self,
531        sql: &str,
532        budget: &mut crate::engine::ExecutionBudget,
533    ) -> Result<Vec<StatementResult>, DbError> {
534        let statements = parse(sql).map_err(|e| {
535            dberr(
536                DbErrorKind::Syntax(e.message.clone()),
537                format!("{} at byte {}", e.message, e.offset),
538            )
539        })?;
540        let mut results = Vec::with_capacity(statements.len());
541        for statement in statements {
542            results.push(self.execute_with_budget(&statement, budget)?);
543        }
544        Ok(results)
545    }
546
547    pub fn commit(self) -> Result<u64, DbError> {
548        let mut budget = crate::engine::ExecutionBudget::unlimited();
549        self.commit_with_budget(&mut budget)
550    }
551
552    pub(crate) fn commit_with_budget(
553        mut self,
554        budget: &mut crate::engine::ExecutionBudget,
555    ) -> Result<u64, DbError> {
556        if !self.active {
557            return Err(dberr(DbErrorKind::Transaction, "transaction is closed"));
558        }
559        self.active = false;
560        if !self.dirty {
561            return Ok(self.db.generation());
562        }
563        budget.state_clone(&self.state, "preparing a database commit")?;
564        self.db.commit_state(self.state, self.base_generation)
565    }
566
567    pub fn rollback(mut self) {
568        self.active = false;
569    }
570
571    pub fn is_active(&self) -> bool {
572        self.active
573    }
574}
575
576fn is_mutation(result: &StatementResult) -> bool {
577    matches!(
578        result,
579        StatementResult::Insert { .. }
580            | StatementResult::Update { .. }
581            | StatementResult::Delete { .. }
582            | StatementResult::CreateTable { .. }
583            | StatementResult::DropTable { .. }
584            | StatementResult::CreateIndex { .. }
585            | StatementResult::DropIndex { .. }
586    )
587}
588
589fn wal_path(path: &Path) -> PathBuf {
590    let mut value = path.as_os_str().to_os_string();
591    value.push(".wal");
592    PathBuf::from(value)
593}
594
595fn acquire_lock(path: &Path) -> Result<File, DbError> {
596    if let Some(parent) = path
597        .parent()
598        .filter(|parent| !parent.as_os_str().is_empty())
599    {
600        fs::create_dir_all(parent).map_err(|error| {
601            dberr(
602                DbErrorKind::Io(format!("create database directory: {error}")),
603                format!("create database directory: {error}"),
604            )
605        })?;
606    }
607    let mut lock_os = path.as_os_str().to_os_string();
608    lock_os.push(".lock");
609    let lock_path = PathBuf::from(lock_os);
610    reject_symlink(&lock_path, "database lock")?;
611    let file = OpenOptions::new()
612        .create(true)
613        .truncate(false)
614        .read(true)
615        .write(true)
616        .open(&lock_path)
617        .map_err(|error| {
618            dberr(
619                DbErrorKind::Io(format!("open database lock: {error}")),
620                format!("open database lock: {error}"),
621            )
622        })?;
623    match fs4::FileExt::try_lock(&file) {
624        Ok(()) => Ok(file),
625        Err(fs4::TryLockError::WouldBlock) => Err(dberr(
626            DbErrorKind::Busy,
627            format!("database is already open: {}", path.display()),
628        )),
629        Err(fs4::TryLockError::Error(error)) => Err(dberr(
630            DbErrorKind::Io(format!("lock database: {error}")),
631            format!("lock database: {error}"),
632        )),
633    }
634}
635
636fn reject_symlink(path: &Path, label: &str) -> Result<(), DbError> {
637    match fs::symlink_metadata(path) {
638        Ok(metadata) if metadata.file_type().is_symlink() => Err(dberr(
639            DbErrorKind::Io(format!("{label} cannot be a symbolic link")),
640            format!("{label} cannot be a symbolic link"),
641        )),
642        Ok(_) => Ok(()),
643        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
644        Err(error) => Err(dberr(
645            DbErrorKind::Io(format!("inspect {label}: {error}")),
646            format!("inspect {label}: {error}"),
647        )),
648    }
649}
650
651fn acquire_workspace_lock(path: &Path) -> Result<Option<File>, DbError> {
652    if !path
653        .file_name()
654        .and_then(|name| name.to_str())
655        .is_some_and(|name| name.eq_ignore_ascii_case("data.basalt"))
656    {
657        return Ok(None);
658    }
659    let Some(parent) = path
660        .parent()
661        .filter(|parent| !parent.as_os_str().is_empty())
662    else {
663        return Ok(None);
664    };
665    let lock_path = parent.join(".workspace.lock");
666    let metadata = match fs::symlink_metadata(&lock_path) {
667        Ok(metadata) => metadata,
668        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
669        Err(error) => {
670            return Err(dberr(
671                DbErrorKind::Io(format!("inspect workspace lock: {error}")),
672                format!("inspect workspace lock: {error}"),
673            ));
674        }
675    };
676    if metadata.file_type().is_symlink() {
677        return Err(dberr(
678            DbErrorKind::Io("workspace lock cannot be a symbolic link".into()),
679            "workspace lock cannot be a symbolic link",
680        ));
681    }
682    let file = OpenOptions::new()
683        .read(true)
684        .write(true)
685        .open(&lock_path)
686        .map_err(|error| {
687            dberr(
688                DbErrorKind::Io(format!("open workspace lock: {error}")),
689                format!("open workspace lock: {error}"),
690            )
691        })?;
692    match fs4::FileExt::try_lock(&file) {
693        Ok(()) => Ok(Some(file)),
694        Err(fs4::TryLockError::WouldBlock) => Err(dberr(
695            DbErrorKind::Busy,
696            format!("workspace is already open: {}", parent.display()),
697        )),
698        Err(fs4::TryLockError::Error(error)) => Err(dberr(
699            DbErrorKind::Io(format!("lock workspace: {error}")),
700            format!("lock workspace: {error}"),
701        )),
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use std::fs;
709
710    #[test]
711    fn durable_commit_reopens() {
712        let dir = std::env::temp_dir().join(format!("basalt-db-{}", std::process::id()));
713        let _ = fs::remove_dir_all(&dir);
714        fs::create_dir_all(&dir).unwrap();
715        let path = dir.join("main.db");
716        {
717            let db = Database::open(&path).unwrap();
718            db.execute_sql(
719                "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT); INSERT INTO t VALUES (1, 'one');",
720            )
721            .unwrap();
722            db.checkpoint().unwrap();
723        }
724        let db = Database::open(&path).unwrap();
725        let result = db.execute_sql("SELECT * FROM t").unwrap();
726        assert!(matches!(
727            &result[0],
728            StatementResult::Select { rows, .. } if rows.len() == 1
729        ));
730        let _ = fs::remove_dir_all(dir);
731    }
732
733    #[test]
734    fn concurrent_snapshot_conflicts() {
735        let db = Database::in_memory();
736        db.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY)")
737            .unwrap();
738        let mut a = db.begin().unwrap();
739        let mut b = db.begin().unwrap();
740        a.execute_sql("INSERT INTO t VALUES (1)").unwrap();
741        b.execute_sql("INSERT INTO t VALUES (2)").unwrap();
742        a.commit().unwrap();
743        assert!(b.commit().is_err());
744    }
745
746    #[test]
747    fn bounded_sql_accounts_for_snapshot_and_keeps_failed_mutations_unpublished() {
748        let database = Database::in_memory();
749        database
750            .execute_sql("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1)")
751            .unwrap();
752
753        let error = database
754            .execute_sql_with_budget("INSERT INTO t VALUES (2)", 5)
755            .unwrap_err();
756
757        assert_eq!(error.kind, DbErrorKind::Limit);
758        assert_eq!(database.row_count("t").unwrap(), 1);
759    }
760
761    #[test]
762    fn bounded_commit_rejects_before_publishing_its_snapshot() {
763        let database = Database::in_memory();
764        database.execute_sql("CREATE TABLE t (id INTEGER)").unwrap();
765        let mut transaction = database.begin().unwrap();
766        transaction.execute_sql("INSERT INTO t VALUES (1)").unwrap();
767        let mut budget = crate::engine::ExecutionBudget::bounded(0);
768
769        let error = transaction.commit_with_budget(&mut budget).unwrap_err();
770
771        assert_eq!(error.kind, DbErrorKind::Limit);
772        assert_eq!(database.row_count("t").unwrap(), 0);
773    }
774
775    #[test]
776    fn replays_wal_and_restores_user_indexes() {
777        let dir = std::env::temp_dir().join(format!("basalt-wal-recovery-{}", std::process::id()));
778        let _ = std::fs::remove_dir_all(&dir);
779        std::fs::create_dir_all(&dir).unwrap();
780        let path = dir.join("main.db");
781        {
782            let db = Database::open(&path).unwrap();
783            db.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)")
784                .unwrap();
785            db.execute_sql("INSERT INTO t VALUES (1, 10), (2, 20), (3, 30)")
786                .unwrap();
787            db.execute_sql("CREATE INDEX value_idx ON t(value)")
788                .unwrap();
789            // Deliberately do not checkpoint: reopening must use the WAL.
790        }
791        let db = Database::open(&path).unwrap();
792        let results = db.execute_sql("SELECT id FROM t WHERE value = 20").unwrap();
793        let StatementResult::Select { rows, .. } = &results[0] else {
794            panic!()
795        };
796        assert_eq!(rows.len(), 1);
797        assert_eq!(db.generation(), 3);
798        let _ = std::fs::remove_dir_all(dir);
799    }
800
801    #[test]
802    fn readers_and_writer_can_share_a_handle() {
803        let db = Database::in_memory();
804        db.execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)")
805            .unwrap();
806        db.execute_sql("INSERT INTO t VALUES (1, 0)").unwrap();
807        let writer_db = db.clone();
808        let writer = std::thread::spawn(move || {
809            for value in 1..=20 {
810                writer_db
811                    .execute_sql(&format!("UPDATE t SET value = {value} WHERE id = 1"))
812                    .unwrap();
813            }
814        });
815        let mut readers = Vec::new();
816        for _ in 0..4 {
817            let reader_db = db.clone();
818            readers.push(std::thread::spawn(move || {
819                for _ in 0..20 {
820                    let result = reader_db.execute_sql("SELECT value FROM t").unwrap();
821                    assert!(matches!(&result[0], StatementResult::Select { rows, .. } if rows.len() == 1));
822                }
823            }));
824        }
825        writer.join().unwrap();
826        for reader in readers {
827            reader.join().unwrap();
828        }
829        let result = db.execute_sql("SELECT value FROM t").unwrap();
830        assert!(
831            matches!(&result[0], StatementResult::Select { rows, .. } if rows[0][0] == crate::types::Value::Integer(20))
832        );
833    }
834
835    #[test]
836    fn relative_paths_are_supported() {
837        let filename = format!("basalt-relative-{}.tmp", std::process::id());
838        let path = std::path::Path::new(&filename);
839        let _ = std::fs::remove_file(path);
840        let _ = std::fs::remove_file(format!("{filename}.wal"));
841        let database = Database::open(path).unwrap();
842        database
843            .execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY)")
844            .unwrap();
845        database.checkpoint().unwrap();
846        assert!(path.exists());
847        drop(database);
848        let _ = std::fs::remove_file(path);
849        let _ = std::fs::remove_file(format!("{filename}.wal"));
850        let _ = std::fs::remove_file(format!("{filename}.lock"));
851    }
852
853    #[test]
854    fn valid_wal_recovers_a_corrupt_snapshot() {
855        let dir =
856            std::env::temp_dir().join(format!("basalt-corrupt-recovery-{}", std::process::id()));
857        let _ = std::fs::remove_dir_all(&dir);
858        std::fs::create_dir_all(&dir).unwrap();
859        let path = dir.join("main.db");
860        {
861            let database = Database::open(&path).unwrap();
862            database
863                .execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (7)")
864                .unwrap();
865        }
866        let mut bytes = std::fs::read(&path).unwrap();
867        bytes[64 + 24] ^= 0xff;
868        std::fs::write(&path, bytes).unwrap();
869        let database = Database::open(&path).unwrap();
870        let result = database.execute_sql("SELECT * FROM t").unwrap();
871        assert!(matches!(&result[0], StatementResult::Select { rows, .. } if rows.len() == 1));
872        let _ = std::fs::remove_dir_all(dir);
873    }
874
875    #[test]
876    fn refuses_to_recover_a_damaged_snapshot_from_an_older_wal() {
877        let dir =
878            std::env::temp_dir().join(format!("basalt-stale-wal-recovery-{}", std::process::id()));
879        let _ = std::fs::remove_dir_all(&dir);
880        std::fs::create_dir_all(&dir).unwrap();
881        let path = dir.join("main.db");
882        {
883            let database = Database::open(&path).unwrap();
884            database
885                .execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY)")
886                .unwrap();
887            database.checkpoint().unwrap();
888        }
889        let empty_payload = State::empty().encode();
890        wal::append(&wal_path(&path), 1, &empty_payload).unwrap();
891        let mut bytes = std::fs::read(&path).unwrap();
892        bytes[64 + 24] ^= 1;
893        std::fs::write(&path, bytes).unwrap();
894
895        let error = match Database::open(&path) {
896            Ok(_) => panic!("damaged snapshot should not recover from an older WAL"),
897            Err(error) => error,
898        };
899
900        assert!(error.message.contains("cannot be safely recovered"));
901        let _ = std::fs::remove_dir_all(dir);
902    }
903
904    #[test]
905    fn rejects_a_same_generation_wal_that_differs_from_the_snapshot() {
906        let dir =
907            std::env::temp_dir().join(format!("basalt-same-generation-wal-{}", std::process::id()));
908        let _ = std::fs::remove_dir_all(&dir);
909        std::fs::create_dir_all(&dir).unwrap();
910        let path = dir.join("main.db");
911        {
912            let database = Database::open(&path).unwrap();
913            database
914                .execute_sql("CREATE TABLE t (id INTEGER PRIMARY KEY)")
915                .unwrap();
916            database.checkpoint().unwrap();
917        }
918        wal::append(&wal_path(&path), 1, &State::empty().encode()).unwrap();
919
920        let error = match Database::open(&path) {
921            Ok(_) => panic!("same-generation WAL mismatch should be rejected"),
922            Err(error) => error,
923        };
924
925        assert!(
926            error
927                .message
928                .contains("same-generation WAL and snapshot differ")
929        );
930        let _ = std::fs::remove_dir_all(dir);
931    }
932
933    #[cfg(unix)]
934    #[test]
935    fn refuses_symbolic_link_database_and_lock_paths() {
936        use std::os::unix::fs::symlink;
937
938        let dir =
939            std::env::temp_dir().join(format!("basalt-database-symlink-{}", std::process::id()));
940        let _ = std::fs::remove_dir_all(&dir);
941        std::fs::create_dir_all(&dir).unwrap();
942
943        let target = dir.join("target.db");
944        let linked = dir.join("linked.db");
945        drop(Database::open(&target).unwrap());
946        symlink(&target, &linked).unwrap();
947        let path_error = match Database::open(&linked) {
948            Ok(_) => panic!("symbolic-link database paths should be rejected"),
949            Err(error) => error,
950        };
951        assert!(
952            path_error
953                .message
954                .contains("database path cannot be a symbolic link")
955        );
956
957        let lock_target = dir.join("lock-target");
958        let lock_path = dir.join("locked.db.lock");
959        std::fs::write(&lock_target, b"").unwrap();
960        symlink(&lock_target, &lock_path).unwrap();
961        let lock_error = match Database::open(dir.join("locked.db")) {
962            Ok(_) => panic!("symbolic-link lock paths should be rejected"),
963            Err(error) => error,
964        };
965        assert!(
966            lock_error
967                .message
968                .contains("database lock cannot be a symbolic link")
969        );
970
971        let _ = std::fs::remove_dir_all(dir);
972    }
973
974    #[test]
975    fn durable_database_rejects_a_second_open_handle() {
976        let dir = std::env::temp_dir().join(format!("basalt-db-lock-{}", std::process::id()));
977        let _ = fs::remove_dir_all(&dir);
978        fs::create_dir_all(&dir).unwrap();
979        let path = dir.join("main.db");
980        let first = Database::open(&path).unwrap();
981        let error = match Database::open(&path) {
982            Ok(_) => panic!("a second database open should be rejected"),
983            Err(error) => error,
984        };
985        assert_eq!(error.kind, DbErrorKind::Busy);
986        assert!(error.message.contains("already open"));
987        drop(first);
988        let second = Database::open(&path).unwrap();
989        drop(second);
990        let _ = fs::remove_dir_all(dir);
991    }
992}