From b26842d4d80c45f969fd244a4a740109316945ed Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Thu, 8 Feb 2024 16:44:18 +0000 Subject: [PATCH] radicle: add cob::cache module Add the cob::cache module which describes an SQLite store and schema for caching COBs. It also provides in-memory and no-cache types for testing purposes. The intention is for the store to be used alongside the existing COB types so that a write-thru cache can be implemented and used for efficient reads. Signed-off-by: Fintan Halpenny X-Clacks-Overhead: GNU Terry Pratchett --- radicle/src/cob.rs | 1 + radicle/src/cob/cache.rs | 265 +++++++++++++++++++++++++ radicle/src/cob/cache/migrations/1.sql | 19 ++ 3 files changed, 285 insertions(+) create mode 100644 radicle/src/cob/cache.rs create mode 100644 radicle/src/cob/cache/migrations/1.sql diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs index b9d7db89..ccecbe48 100644 --- a/radicle/src/cob.rs +++ b/radicle/src/cob.rs @@ -1,4 +1,5 @@ #![warn(clippy::unwrap_used)] +pub mod cache; pub mod common; pub mod identity; pub mod issue; diff --git a/radicle/src/cob/cache.rs b/radicle/src/cob/cache.rs new file mode 100644 index 00000000..86b13b55 --- /dev/null +++ b/radicle/src/cob/cache.rs @@ -0,0 +1,265 @@ +use std::collections::HashMap; +use std::convert::Infallible; +use std::fmt; +use std::marker::PhantomData; +use std::path::Path; +use std::sync::Arc; +use std::time; + +use radicle_cob::ObjectId; +use sqlite as sql; +use thiserror::Error; + +use crate::prelude::RepoId; +use crate::sql::transaction; + +/// File suffix for storing the COBs database file. +pub const COBS_DB_FILE: &str = "cache.db"; + +/// How long to wait for the database lock to be released before failing a read. +const DB_READ_TIMEOUT: time::Duration = time::Duration::from_secs(3); +/// How long to wait for the database lock to be released before failing a write. +const DB_WRITE_TIMEOUT: time::Duration = time::Duration::from_secs(6); + +/// Database migrations. +/// The first migration is the creation of the initial tables. +const MIGRATIONS: &[&str] = &[include_str!("cache/migrations/1.sql")]; + +#[derive(Error, Debug)] +pub enum Error { + /// An Internal error. + #[error("internal error: {0}")] + Internal(#[from] sql::Error), + /// No rows returned in query result. + #[error("no rows returned")] + NoRows, +} + +/// Read-only type witness. +#[derive(Clone)] +pub struct Read; +/// Read-write type witness. +#[derive(Clone)] +pub struct Write; + +/// A file-backed database storing information about the network. +#[derive(Clone)] +pub struct Store { + pub(super) db: Arc, + marker: PhantomData, +} + +impl fmt::Debug for Store { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Database").finish() + } +} + +impl Store { + /// Same as [`Self::open`], but in read-only mode. This is useful to have multiple + /// open databases, as no locking is required. + pub fn reader>(path: P) -> Result { + let mut db = sql::Connection::open_thread_safe_with_flags( + path, + sqlite::OpenFlags::new().with_read_only(), + )?; + db.set_busy_timeout(DB_READ_TIMEOUT.as_millis() as usize)?; + + Ok(Self { + db: Arc::new(db), + marker: PhantomData, + }) + } + + /// Create a new in-memory database. + pub fn memory() -> Result { + let mut db = sql::Connection::open_thread_safe_with_flags( + ":memory:", + sqlite::OpenFlags::new().with_read_only(), + )?; + db.set_busy_timeout(DB_READ_TIMEOUT.as_millis() as usize)?; + + Ok(Self { + db: Arc::new(db), + marker: PhantomData, + }) + } +} + +impl Store { + /// Open a database at the given path. Creates a new database if it + /// doesn't exist. + pub fn open>(path: P) -> Result { + let mut db = sql::Connection::open_thread_safe(path)?; + db.set_busy_timeout(DB_WRITE_TIMEOUT.as_millis() as usize)?; + migrate(&db)?; + + Ok(Self { + db: Arc::new(db), + marker: PhantomData, + }) + } + + /// Create a new in-memory database. + pub fn memory() -> Result { + let db = Arc::new(sql::Connection::open_thread_safe(":memory:")?); + migrate(&db)?; + + Ok(Self { + db, + marker: PhantomData, + }) + } + + /// Turn this handle into a read-only handle. + pub fn read_only(self) -> Store { + Store { + db: self.db, + marker: PhantomData, + } + } + + /// Perform a raw query on the database handle. + pub fn raw_query(&self, query: F) -> Result + where + F: FnOnce(&sql::Connection) -> Result, + E: From, + { + transaction(&self.db, query) + } +} + +impl Store { + /// Get the database version. This is updated on schema changes. + pub fn version(&self) -> Result { + version(&self.db) + } +} + +/// Get the `user_version` value from the database header. +pub fn version(db: &sql::Connection) -> Result { + let version = db + .prepare("PRAGMA user_version")? + .into_iter() + .next() + .ok_or(Error::NoRows)?? + .read::(0); + + Ok(version as usize) +} + +/// Bump the `user_version` value. +fn bump(db: &sql::Connection) -> Result { + let old = version(db)?; + let new = old + 1; + + db.execute(format!("PRAGMA user_version = {new}"))?; + + Ok(new as usize) +} + +/// Migrate the database to the latest schema. +fn migrate(db: &sql::Connection) -> Result { + let mut version = version(db)?; + for (i, migration) in MIGRATIONS.iter().enumerate() { + if i >= version { + transaction(db, |db| { + db.execute(migration)?; + version = bump(db)?; + + Ok::<_, Error>(()) + })?; + } + } + Ok(version) +} + +/// Update a COB object in the cache. +pub trait Update { + /// The output type, if any, for a successful update. + type Out; + type UpdateError: std::error::Error + Send + Sync + 'static; + + fn update( + &mut self, + rid: &RepoId, + id: &ObjectId, + object: &T, + ) -> Result; +} + +/// Remove a COB object in the cache. +pub trait Remove { + /// The output type, if any, for a successful removal. + type Out; + type RemoveError: std::error::Error + Send + Sync + 'static; + + fn remove(&mut self, rid: &RepoId, id: &ObjectId) -> Result; +} + +/// An in-memory cache for storing COB objects. +/// +/// The intention is for this to be used in tests that expect cache +/// reads. +#[derive(Clone, Debug)] +pub struct InMemory { + inner: HashMap>, +} + +impl Default for InMemory { + fn default() -> Self { + Self { + inner: HashMap::new(), + } + } +} + +impl Update for InMemory +where + T: Clone, +{ + type Out = Option; + type UpdateError = Infallible; + + fn update( + &mut self, + rid: &RepoId, + id: &ObjectId, + object: &T, + ) -> Result { + let objects = self.inner.entry(*rid).or_default(); + Ok(objects.insert(*id, object.clone())) + } +} + +/// The `/dev/null` of caches. +/// +/// It will ignore any updates, and successfully return on each call +/// of [`Update::update`]. +/// +/// The intention is for this to be used in tests that do not expect +/// any cache reads. +pub struct NoCache; + +impl Update for NoCache { + type Out = (); + type UpdateError = Infallible; + + fn update( + &mut self, + _rid: &RepoId, + _id: &ObjectId, + _object: &T, + ) -> Result { + Ok(()) + } +} + +impl Remove for NoCache { + type Out = (); + type RemoveError = Infallible; + + fn remove(&mut self, _rid: &RepoId, _id: &ObjectId) -> Result { + Ok(()) + } +} diff --git a/radicle/src/cob/cache/migrations/1.sql b/radicle/src/cob/cache/migrations/1.sql new file mode 100644 index 00000000..eeb13471 --- /dev/null +++ b/radicle/src/cob/cache/migrations/1.sql @@ -0,0 +1,19 @@ +-- Issues +create table if not exists "issues" ( + -- Issue ID + "id" text primary key not null, + -- Repository ID + "repo" text not null, + -- Issue in JSON format + "issue" text not null +) strict; + +-- Patches +create table if not exists "patches" ( + -- Patch ID + "id" text primary key not null, + -- Repository ID + "repo" text not null, + -- Patch in JSON format + "patch" text not null +) strict;