Database API

Before we dive into the theory and implementation of my toy database, let's first look at the toy database's API, which consists of the following methods:

  • set_time
  • begin_txn
  • write
  • read
  • read_without_txn
  • abort_txn
  • commit_txn
  • run_txn

Here is an example of using the database:

#![allow(unused)]
fn main() {
let db = DB::new("./tmp/data", Timestamp::new(10))
let txn1 = db.begin_txn().await;
let value = db.read::<String>("foo", txn1).await.unwrap();
if value == "bar" {
	db.write("baz", 20, txn1).await.unwrap();
}
let commit_result = db.commit_txn(txn1).await;
}

In the code snippet above, we created a database by providing a path to specify where to store the records. We then began a transaction, performed a write and a read, then committed the transaction.

An alternative way to perform transactions is with the run_txn method. In the snippet below, the run_txn function automatically begins a transaction and commits the transaction at the end of the function scope. It would also abort the transaction if the inner function panics.

#![allow(unused)]
fn main() {
db.run_txn(|txn_context| async move {
		let value = txn_context.read::<i32>("foo").await;
		if value == "bar" {
	    txn_context.write("foo", 12).await.unwrap();
    }
})
}

For more examples, feel free to check out the unit tests I wrote for my database.

Thread-safe

The database is thread-safe. If you wrap the database instance around an Arc, you can safely use it across different threads. For example:

#![allow(unused)]
fn main() {
let db = Arc::new(DB::new("./tmp/data", Timestamp::new(10)));

let db_1 = Arc::clone(db);
let key1 = "foo";
let key2 = "bar";
let task_1 = tokio::spawn(async move {
    db_1.run_txn(|txn_context| async move {
      txn_context.write(key1, 1).await.unwrap();
      txn_context.write(key2, 10).await.unwrap();
    })
    .await
});

let db_2 = Arc::clone(db);
let task_2 = tokio::spawn(async move {
    db_2.run_txn(|txn_context| async move {
      txn_context.write(key1, 2).await.unwrap();
      txn_context.write(key2, 20).await.unwrap();
    })
    .await;
});
tokio::try_join!(task_1, task_2).unwrap();
}

In the example above, the serializability of the database guarantees that either all of task1 is executed first or all of task2 is executed first.

Database Clock

The database is powered by a Hybrid Logical Clock (which we will cover later). The developer can choose to create a database instance that uses the system's time or a manual clock. A manual clock requires the developer to manually increment the physical time with the set_time function. This is useful for writing unit tests.