1use 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#[derive(Clone)]
31pub struct Database {
32 inner: Arc<Inner>,
33}
34
35impl Database {
36 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 pub fn open(path: impl AsRef<Path>) -> Result<Database, DbError> {
53 Self::open_internal(path, false)
54 }
55
56 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 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 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 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 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 pub fn transaction(&self) -> Result<Transaction, DbError> {
175 self.begin()
176 }
177
178 pub fn connect(&self) -> Connection {
181 Connection {
182 db: self.clone(),
183 transaction: None,
184 }
185 }
186
187 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 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 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 pub fn generation(&self) -> u64 {
305 self.inner.generation.load(Ordering::Acquire)
306 }
307
308 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 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 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
383pub 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 pub fn generation(&self) -> u64 {
479 self.db.generation()
480 }
481}
482
483pub 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 }
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}