-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Apply some SQLite lessons #10
Changes from all commits
355823f
6b9c37a
16566b7
366a47a
913d108
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,61 @@ | ||
use std::path::{Path, PathBuf}; | ||
use std::time::Duration; | ||
|
||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; | ||
use thiserror::Error; | ||
|
||
/// A connection to an sqlite DB holding our bookmark data. | ||
pub struct Connection { | ||
pub(crate) db: sqlx::sqlite::SqlitePool, | ||
pub(crate) rw: sqlx::sqlite::SqlitePool, | ||
pub(crate) ro: Option<sqlx::sqlite::SqlitePool>, | ||
} | ||
|
||
/// Error establishing sqlite connection pools to a database at a given path. | ||
#[derive(Error, Debug)] | ||
#[error("could not open database file {path}")] | ||
pub struct ConnectionFromPathFailed { | ||
path: PathBuf, | ||
source: sqlx::Error, | ||
} | ||
|
||
impl Connection { | ||
/// Create a database connection to a file on disk. | ||
pub async fn from_path(path: &Path) -> Result<Self, ConnectionFromPathFailed> { | ||
let options = SqliteConnectOptions::new() | ||
.filename(&path) | ||
// Options from https://kerkour.com/sqlite-for-servers: | ||
.journal_mode(SqliteJournalMode::Wal) | ||
.busy_timeout(Duration::from_secs(5)) | ||
.synchronous(SqliteSynchronous::Normal) | ||
.pragma("cache_size", "1000000000") | ||
.foreign_keys(true) | ||
.pragma("temp_store", "memory") | ||
// Some settings that just seem like a good idea: | ||
.shared_cache(true) | ||
.optimize_on_close(true, None); | ||
|
||
let rw = SqlitePoolOptions::new() | ||
.max_connections(1) | ||
.connect_with(options.clone()) | ||
.await | ||
.map_err(|source| ConnectionFromPathFailed { | ||
path: path.to_owned(), | ||
source, | ||
})?; | ||
let ro = Some( | ||
SqlitePoolOptions::new() | ||
.connect_with(options.read_only(true)) | ||
.await | ||
.map_err(|source| ConnectionFromPathFailed { | ||
path: path.to_owned(), | ||
source, | ||
})?, | ||
); | ||
Ok(Connection { rw, ro }) | ||
} | ||
|
||
/// Create a database connection from an open SqlitePool. | ||
pub fn from_pool(db: sqlx::sqlite::SqlitePool) -> Self { | ||
Self { db } | ||
pub fn from_pool(rw: sqlx::sqlite::SqlitePool) -> Self { | ||
Self { rw, ro: None } | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,23 @@ | ||
use std::marker::PhantomData; | ||
|
||
use crate::Connection; | ||
|
||
/// The mode that the transaction is in: Either read-write or read-only. | ||
/// | ||
/// This is an optimization for reducing timeouts / lock contention on | ||
/// readonly/read-write txns in a sqlite database, but should also | ||
/// help with correctness and safety: A readonly transaction does not | ||
/// have read-write methods defined. | ||
pub trait TransactionMode {} | ||
|
||
/// Read-only transaction mode. See [Transaction]. | ||
pub struct ReadOnly {} | ||
impl TransactionMode for ReadOnly {} | ||
|
||
/// Read-write transaction mode. See [Transaction]. | ||
pub struct ReadWrite {} | ||
impl TransactionMode for ReadWrite {} | ||
|
||
/// A database transaction, operating on the behalf of an `lz` user. | ||
/// | ||
/// Transactions are the main way that `lz` code uses the database: | ||
|
@@ -10,9 +28,23 @@ use crate::Connection; | |
/// an HTTP request), the transaction needs to be | ||
/// [`commit`][Transaction::commit]ed. | ||
#[derive(Debug)] | ||
pub struct Transaction { | ||
pub struct Transaction<M: TransactionMode = ReadWrite> { | ||
txn: sqlx::Transaction<'static, sqlx::sqlite::Sqlite>, | ||
user: User<UserId>, | ||
marker: PhantomData<M>, | ||
} | ||
|
||
/// An error that can occur when beginning a readonly transaction for a user. | ||
#[derive(thiserror::Error, Debug)] | ||
pub enum RoTransactionError { | ||
/// Any error raised by sqlx. | ||
#[error("sql datastore error")] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it worth breaking this out into narrower errors? The one that seems most plausible to come up on a regular basis is a file-not-found error if you fat-finger the path. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point - this does need the file name added to a context from the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, also - the |
||
Sqlx(#[from] sqlx::Error), | ||
|
||
/// User doesn't exist and can not be created due to the read-only | ||
/// nature of the connection. | ||
#[error("username {user} does not yet exist")] | ||
UserNotFound { user: String }, | ||
} | ||
|
||
/// # Transactions | ||
|
@@ -28,19 +60,58 @@ impl Connection { | |
/// | ||
/// In order to commit the changes that happened in the | ||
/// transaction, call [`Transaction::commit`]. | ||
pub async fn begin_for_user(&self, username: &str) -> Result<Transaction, sqlx::Error> { | ||
let mut txn = self.db.begin().await?; | ||
pub async fn begin_for_user( | ||
&self, | ||
username: &str, | ||
) -> Result<Transaction<ReadWrite>, sqlx::Error> { | ||
let mut txn = self.rw.begin().await?; | ||
let user = Connection::ensure_user(&mut txn, username).await?; | ||
Ok(Transaction { txn, user }) | ||
Ok(Transaction { | ||
txn, | ||
user, | ||
marker: PhantomData, | ||
}) | ||
} | ||
|
||
/// Begin a new read-only transaction as a given user. | ||
/// | ||
/// If the user with the given name doesn't exist yet, this raises | ||
/// an error that the database is opened in read-only mode. | ||
/// | ||
/// In order to commit the changes that happened in the | ||
/// transaction, call [`Transaction::commit`]. | ||
pub async fn begin_ro_for_user( | ||
&self, | ||
username: &str, | ||
) -> Result<Transaction<ReadOnly>, RoTransactionError> { | ||
let mut txn = if let Some(ro) = &self.ro { | ||
ro.begin() | ||
} else { | ||
self.rw.begin() | ||
} | ||
.await?; | ||
let user = Connection::get_user(&mut txn, username) | ||
.await? | ||
.ok_or_else(|| RoTransactionError::UserNotFound { | ||
user: username.to_string(), | ||
})?; | ||
Ok(Transaction { | ||
txn, | ||
user, | ||
marker: PhantomData, | ||
}) | ||
} | ||
} | ||
|
||
impl Transaction { | ||
impl Transaction<ReadWrite> { | ||
/// Commits the transaction. | ||
pub async fn commit(self) -> Result<(), sqlx::Error> { | ||
self.txn.commit().await | ||
} | ||
} | ||
|
||
impl<M: TransactionMode> Transaction<M> { | ||
/// Return the user whom the transaction is concerning. | ||
pub fn user(&self) -> &User<UserId> { | ||
&self.user | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand the distinction from anyhow, but it boggles my mind that Rust projects reliably need two different third-party error crates.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hahaha, yep. There used to be a single one error handling crate, and it did kinda a less-great job doing both things... so now we have two with smaller circles of responsibility each.