diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 8b24ce47f..acf0b2262 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -4635,6 +4635,7 @@ dependencies = [ "radicle", "serde", "serde_json", + "sqlite", "thiserror 2.0.19", ] diff --git a/desktop/crates/radicle-board/Cargo.toml b/desktop/crates/radicle-board/Cargo.toml index b27450b59..2e1a89917 100644 --- a/desktop/crates/radicle-board/Cargo.toml +++ b/desktop/crates/radicle-board/Cargo.toml @@ -10,7 +10,11 @@ log = { version = "0.4" } radicle = { version = "0.25" } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } +sqlite = { version = "0.37", features = ["bundled"], optional = true } thiserror = { version = "2.0.12" } [dev-dependencies] radicle = { version = "0.25", features = ["test"] } + +[features] +sqlite = ["dep:sqlite"] diff --git a/desktop/crates/radicle-board/examples/bench_cache.rs b/desktop/crates/radicle-board/examples/bench_cache.rs new file mode 100644 index 000000000..f8620f0f8 --- /dev/null +++ b/desktop/crates/radicle-board/examples/bench_cache.rs @@ -0,0 +1,271 @@ +//! Times reading a board with thousands of operations: plain evaluation, +//! memoised evaluation, and reads through the cache. +//! +//! ```sh +//! cargo run --release --example bench_cache -p radicle-board --features sqlite +//! BENCH_OPS=5000 BENCH_ISSUES=300 cargo run --release --example bench_cache \ +//! -p radicle-board --features sqlite +//! ``` +//! +//! Operations are written directly to git, as heartwood's write path would +//! evaluate the whole board twice per operation. + +#![allow(clippy::unwrap_used)] +#![cfg_attr(not(feature = "sqlite"), allow(dead_code, unused_imports))] + +use std::hint::black_box; +use std::time::{Duration, Instant}; + +use radicle::cob::cache::NoCache; +use radicle::cob::issue::Issues; +use radicle::cob::store::access::{ReadOnly, WriteAs}; +use radicle::cob::store::{CobAction, encoding}; +use radicle::cob::{self, Contents, ObjectId, Title}; +use radicle::crypto::{Signer as _, SigningKey}; +use radicle::git::Oid; +use radicle::identity::Did; +use radicle::storage::ReadRepository; +use radicle::storage::git::Repository; +use radicle::test; +use radicle_board::{Action, Board, BoardId, Boards, Card, TYPENAME, position}; + +fn env_usize(key: &str, default: usize) -> usize { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// A small deterministic generator, so runs are comparable. +struct Random(u64); + +impl Random { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +/// Writes `actions` as one operation by `signer` on top of `tips`, without +/// evaluating the board, and returns the operation's id. +fn write_op( + repo: &Repository, + signer: &SigningKey, + id: &BoardId, + tips: Vec, + actions: Vec, +) -> Oid { + let related = actions.iter().flat_map(CobAction::parents).collect(); + let contents = Contents::from_vec( + actions + .into_iter() + .map(|a| encoding::encode(a).unwrap()) + .collect(), + ) + .unwrap(); + let entry = cob::change::Storage::store( + repo, + Some(repo.identity_head().unwrap()), + related, + signer, + cob::change::Template { + type_name: TYPENAME.clone(), + tips, + message: "Update board".into(), + embeds: vec![], + contents, + }, + ) + .unwrap(); + cob::object::Storage::update(repo, signer.public_key(), &TYPENAME, id, &entry.id).unwrap(); + entry.id +} + +fn time(label: &str, iters: u32, mut f: impl FnMut()) -> Duration { + let start = Instant::now(); + for _ in 0..iters { + f(); + } + let mean = start.elapsed() / iters; + println!(" {label:<44} {mean:>10.2?}"); + mean +} + +#[cfg(not(feature = "sqlite"))] +fn main() {} + +#[cfg(feature = "sqlite")] +fn main() { + let n_ops = env_usize("BENCH_OPS", 3000); + let n_issues = env_usize("BENCH_ISSUES", 200); + let mut random = Random(0x9e37_79b9_7f4a_7c15); + + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let carol = SigningKey::mock(2); + let columns = ["backlog", "todo", "in-progress", "in-review"]; + + println!("building fixture: {n_issues} issues, {n_ops} operations by three authors ..."); + let build = Instant::now(); + let mut cache = NoCache; + let mut issues = Issues::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let mut alices = Vec::new(); + let mut assigned = Vec::new(); + for i in 0..n_issues { + let issue = issues + .create( + Title::new(&format!("Issue {i}")).unwrap(), + "", + &[], + &[], + [], + &mut cache, + ) + .unwrap(); + let id = *issue.id(); + if i % 3 == 0 { + let proof = issues + .get_mut(&id, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + assigned.push((id, proof)); + } else { + alices.push(id); + } + } + let mut carols = Vec::new(); + let mut carol_issues = Issues::open(&*repo, WriteAs::new(&carol)).unwrap(); + for i in 0..n_issues / 4 { + let issue = carol_issues + .create( + Title::new(&format!("Carol's issue {i}")).unwrap(), + "", + &[], + &[], + [], + &mut cache, + ) + .unwrap(); + carols.push(*issue.id()); + } + let card = |oid: ObjectId| Card { + rid: repo.id(), + type_name: cob::issue::TYPENAME.clone(), + oid, + }; + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + + let mut tips = vec![*id]; + let mut previous = tips.clone(); + for i in 0..n_ops { + let column = columns[random.below(columns.len())].to_string(); + let pos = position::between(None, None).unwrap(); + let (signer, actions) = match random.below(10) { + 0..=5 => { + let n = 1 + random.below(3); + let actions = (0..n) + .map(|_| { + let oid = match random.below(4) { + 0 => assigned[random.below(assigned.len())].0, + 1 => carols[random.below(carols.len())], + _ => alices[random.below(alices.len())], + }; + boards.move_action(card(oid), column.clone(), pos.clone(), None) + }) + .collect(); + (&alice.signer, actions) + } + 6..=7 => { + let (oid, proof) = assigned[random.below(assigned.len())]; + let action = boards.move_action(card(oid), column.clone(), pos, Some(proof)); + (&bob, vec![action]) + } + 8 => { + let oid = alices[random.below(alices.len())]; + let action = boards.move_action(card(oid), column.clone(), pos, None); + (&bob, vec![action]) + } + _ => { + let oid = carols[random.below(carols.len())]; + let action = boards.move_action(card(oid), column.clone(), pos, None); + (&carol, vec![action]) + } + }; + // Every tenth operation is concurrent with the one before it. + let parents = if i % 10 == 9 { + previous.clone() + } else { + tips.clone() + }; + let head = write_op(&repo, signer, &id, parents.clone(), actions); + previous = tips.clone(); + tips.retain(|tip| !parents.contains(tip)); + tips.push(head); + } + println!( + "fixture built in {:.2?}: {} tips across {} namespaces\n", + build.elapsed(), + tips.len(), + repo.backend + .references_glob(&radicle_board::REFS) + .unwrap() + .count() + ); + + let plain = Boards::open(&*repo, ReadOnly).unwrap(); + let placed = plain.get(&id).unwrap().unwrap().cards().len(); + println!("the board places {placed} cards\n"); + + let unmemoised = time("evaluation without memoisation", 3, || { + let board = cob::get::(&*repo, &TYPENAME, &id).unwrap(); + black_box(board); + }); + let memoised = time("evaluation with memoisation", 3, || { + black_box(plain.get(&id).unwrap()); + }); + + let path = radicle_board::cache_db_path(&alice.root); + let cached = Boards::open_cached(&*repo, ReadOnly, &path).unwrap(); + let cold = time("cached get, cold (evaluate and store)", 1, || { + black_box(cached.get(&id).unwrap()); + }); + let warm = time("cached get, warm", 20, || { + black_box(cached.get(&id).unwrap()); + }); + let list = time("cached list, warm", 20, || { + black_box(cached.list().unwrap()); + }); + + let first = position::between(None, None).unwrap(); + let action = boards.move_action(card(carols[0]), "todo".into(), first.clone(), None); + write_op(&repo, &carol, &id, tips.clone(), vec![action]); + let fetched = time("cached get after a fetched operation", 1, || { + black_box(cached.get(&id).unwrap()); + }); + let write = time("update through heartwood (two evaluations)", 1, || { + let mut boards = Boards::open_cached(&*repo, WriteAs::new(&alice.signer), &path).unwrap(); + let action = boards.move_action(card(alices[0]), "todo".into(), first.clone(), None); + black_box(boards.update(&alice.storage, &id, vec![action]).unwrap()); + }); + let after_write = time("cached get after our own write", 20, || { + black_box(cached.get(&id).unwrap()); + }); + + println!("\nsummary for {n_ops} operations:"); + println!( + " memoisation {:.1}x, warm cache {:.0}x over plain evaluation", + unmemoised.as_secs_f64() / memoised.as_secs_f64(), + unmemoised.as_secs_f64() / warm.as_secs_f64() + ); + black_box((cold, list, fetched, write, after_write)); +} diff --git a/desktop/crates/radicle-board/src/cache.rs b/desktop/crates/radicle-board/src/cache.rs new file mode 100644 index 000000000..c8c4f08ff --- /dev/null +++ b/desktop/crates/radicle-board/src/cache.rs @@ -0,0 +1,201 @@ +//! A SQLite cache of evaluated boards. Heartwood only caches its own +//! object types, so without it every read replays every board's history. +//! +//! A board's state is a function of its tips: an operation's commit has the +//! identity document, card root and proof it checks as parents, so they +//! replicate with it and never change. A row is current while its token, +//! made of the sorted tips and the format versions, matches the references. +//! A changed board is evaluated whole: operations are ordered by timestamp, +//! so a fetched one can sort before those already applied. +//! +//! The node fetches other peers' operations in the background and doesn't +//! know about this cache, so reads check freshness against the references +//! rather than trusting a hook. A read refreshes stale rows and prunes +//! boards whose references are gone. + +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::Duration; + +use radicle::git::Oid; +use radicle::identity::RepoId; +use sqlite as sql; + +use crate::{Board, BoardId, VERSION}; + +/// Changes to the evaluator's rules change what a board evaluates to, so +/// every such change must bump this: rows with an older token are then +/// evaluated again on read. +const FORMAT: u32 = 5; + +const DB_FILE: &str = "boards.db"; + +const DB_TIMEOUT: Duration = Duration::from_secs(6); + +const MIGRATIONS: &[&str] = &["CREATE TABLE IF NOT EXISTS boards ( + id TEXT NOT NULL, + repo TEXT NOT NULL, + head TEXT NOT NULL, + board TEXT NOT NULL, + PRIMARY KEY (repo, id) + ) STRICT;"]; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("sqlite: {0}")] + Sql(#[from] sql::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("parse: {0}")] + Parse(String), + #[error("no rows returned")] + NoRows, +} + +/// The cache database within a profile's COBs directory. +pub fn db_path(cobs: impl AsRef) -> PathBuf { + cobs.as_ref().join(DB_FILE) +} + +/// The freshness token of a board at `tips`. +pub fn head_token<'a>(tips: impl IntoIterator) -> String { + let mut tips: Vec = tips.into_iter().map(Oid::to_string).collect(); + tips.sort(); + format!("v{FORMAT}.{VERSION}:{}", tips.join(",")) +} + +/// A cached board: its token and its JSON. +pub struct Row { + pub head: String, + pub board: String, +} + +pub struct Store { + db: sql::ConnectionThreadSafe, +} + +impl Store { + /// Opens the cache at `path`, creating and migrating it as needed. + pub fn open(path: impl AsRef) -> Result { + let mut db = sql::Connection::open_thread_safe(path)?; + db.set_busy_timeout(DB_TIMEOUT.as_millis() as usize)?; + // Both are harmless no-ops for in-memory databases. + let _ = db.execute("PRAGMA journal_mode = WAL"); + let _ = db.execute("PRAGMA synchronous = NORMAL"); + let store = Self { db }; + store.migrate()?; + + Ok(store) + } + + fn migrate(&self) -> Result<(), Error> { + transaction(&self.db, |db| { + let mut version = user_version(db)?; + while version < MIGRATIONS.len() { + db.execute(MIGRATIONS[version])?; + db.execute(format!("PRAGMA user_version = {}", version + 1))?; + version += 1; + } + Ok(()) + }) + } + + pub fn update( + &self, + repo: &RepoId, + id: &BoardId, + head: &str, + board: &Board, + ) -> Result<(), Error> { + let board = serde_json::to_string(board)?; + transaction(&self.db, |db| { + let mut stmt = db.prepare( + "INSERT INTO boards (id, repo, head, board) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (repo, id) DO UPDATE SET head = ?3, board = ?4", + )?; + stmt.bind((1, id.to_string().as_str()))?; + stmt.bind((2, repo.to_string().as_str()))?; + stmt.bind((3, head))?; + stmt.bind((4, board.as_str()))?; + stmt.next()?; + Ok(()) + }) + } + + pub fn remove(&self, repo: &RepoId, id: &BoardId) -> Result<(), Error> { + transaction(&self.db, |db| { + let mut stmt = db.prepare("DELETE FROM boards WHERE repo = ?1 AND id = ?2")?; + stmt.bind((1, repo.to_string().as_str()))?; + stmt.bind((2, id.to_string().as_str()))?; + stmt.next()?; + Ok(()) + }) + } + + pub fn get(&self, repo: &RepoId, id: &BoardId) -> Result, Error> { + let mut stmt = self + .db + .prepare("SELECT head, board FROM boards WHERE repo = ?1 AND id = ?2")?; + stmt.bind((1, repo.to_string().as_str()))?; + stmt.bind((2, id.to_string().as_str()))?; + match stmt.into_iter().next().transpose()? { + None => Ok(None), + Some(row) => Ok(Some(Row { + head: row.try_read::<&str, _>("head")?.to_string(), + board: row.try_read::<&str, _>("board")?.to_string(), + })), + } + } + + pub fn rows(&self, repo: &RepoId) -> Result, Error> { + let mut stmt = self + .db + .prepare("SELECT id, head, board FROM boards WHERE repo = ?1")?; + stmt.bind((1, repo.to_string().as_str()))?; + let mut rows = Vec::new(); + for row in stmt.into_iter() { + let row = row?; + let id = BoardId::from_str(row.try_read::<&str, _>("id")?) + .map_err(|e| Error::Parse(format!("board id: {e}")))?; + rows.push(( + id, + Row { + head: row.try_read::<&str, _>("head")?.to_string(), + board: row.try_read::<&str, _>("board")?.to_string(), + }, + )); + } + + Ok(rows) + } +} + +/// Runs `f` in a write transaction, taking the write lock up front so that +/// two processes can't deadlock upgrading theirs. +fn transaction( + db: &sql::ConnectionThreadSafe, + f: impl FnOnce(&sql::ConnectionThreadSafe) -> Result, +) -> Result { + db.execute("BEGIN IMMEDIATE")?; + match f(db) { + Ok(value) => { + db.execute("COMMIT")?; + Ok(value) + } + Err(err) => { + let _ = db.execute("ROLLBACK"); + Err(err) + } + } +} + +fn user_version(db: &sql::ConnectionThreadSafe) -> Result { + let version = db + .prepare("PRAGMA user_version")? + .into_iter() + .next() + .ok_or(Error::NoRows)?? + .read::(0); + + Ok(version as usize) +} diff --git a/desktop/crates/radicle-board/src/lib.rs b/desktop/crates/radicle-board/src/lib.rs index 96768710e..e4ef57699 100644 --- a/desktop/crates/radicle-board/src/lib.rs +++ b/desktop/crates/radicle-board/src/lib.rs @@ -11,8 +11,9 @@ //! issue's own rules: only a delegate or the author moves an issue into or //! out of one, and patches never enter one. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::ops::Deref; +use std::rc::Rc; use std::str::FromStr; use std::sync::LazyLock; @@ -20,18 +21,30 @@ use radicle::cob::store::{self, CobAction, CobWithType, access}; use radicle::cob::{self, ActorId, Evaluate, ObjectId, Op, Timestamp, TypeName}; use radicle::crypto; use radicle::git::Oid; +use radicle::git::fmt::RefString; use radicle::identity::{Did, DocAt, RepoId}; use radicle::node::NodeId; +use radicle::storage::git::Repository; use radicle::storage::{ ReadRepository, ReadStorage, RepositoryError, SignRepository, WriteRepository, }; use serde::{Deserialize, Serialize}; +#[cfg(feature = "sqlite")] +pub mod cache; pub mod position; +#[cfg(feature = "sqlite")] +pub use cache::db_path as cache_db_path; + pub static TYPENAME: LazyLock = LazyLock::new(|| FromStr::from_str("works.monadic.board").expect("type name is valid")); +/// A glob of every board's references, across all namespaces. It has two +/// wildcards, so it is for git rather than heartwood's reference patterns. +pub static REFS: LazyLock = + LazyLock::new(|| format!("refs/namespaces/*/refs/cobs/{}/*", TYPENAME.as_str())); + /// The newest format version this crate reads. Heartwood writes every /// operation as version 1 and gives writers no way to choose, so raising this /// needs heartwood to let us set the manifest version first. @@ -188,9 +201,78 @@ pub struct Board { name: String, created_at: Timestamp, columns: Vec, + #[serde(with = "cards")] cards: BTreeMap, } +/// Cards as a list of `[card, placement]` pairs, since JSON keys are strings. +mod cards { + use std::collections::BTreeMap; + + use serde::{Deserialize, Deserializer, Serializer}; + + use super::{Card, Placement}; + + pub fn serialize( + cards: &BTreeMap, + serializer: S, + ) -> Result { + serializer.collect_seq(cards) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Vec::<(Card, Placement)>::deserialize(deserializer).map(|cards| cards.into_iter().collect()) + } +} + +/// What one evaluation has loaded so far. An identity document is pinned by +/// its commit and a card's root by its id, so neither can read differently +/// later in the same evaluation. +#[derive(Default)] +struct Memo { + docs: HashMap>>, + roots: HashMap>, +} + +impl Memo { + fn doc(&mut self, head: Oid, repo: &R) -> Option> { + self.docs + .entry(head) + .or_insert_with(|| match repo.identity_doc_at(head) { + Ok(doc) => Some(Rc::new(doc)), + Err(e) => { + log::warn!(target: "board", "Ignoring operations with identity {head} that doesn't load: {e}"); + None + } + }) + .clone() + } + + /// The author and type of `oid` if it is a verified root operation. + fn root(&mut self, oid: ObjectId, repo: &R) -> Option<(ActorId, TypeName)> + where + R: cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, + { + self.roots + .entry(oid) + .or_insert_with(|| { + let root = cob::change::Storage::load(repo, *oid).ok()?; + if !root.valid_signatures() || !root.parents.is_empty() { + return None; + } + Some((*root.author(), root.manifest.type_name.clone())) + }) + .clone() + } +} + impl Board { pub fn name(&self) -> &str { &self.name @@ -308,6 +390,7 @@ fn authorized( parents: &[Oid], doc: &DocAt, repo: &R, + memo: &mut Memo, ) -> bool where R: ReadRepository @@ -347,16 +430,13 @@ where return false; } - let Ok(root) = cob::change::Storage::load(repo, *card.oid) else { + let Some((root_author, type_name)) = memo.root(card.oid, repo) else { return false; }; - if !root.valid_signatures() - || root.manifest.type_name != card.type_name - || !root.parents.is_empty() - { + if type_name != card.type_name { return false; } - if delegate || *root.author() == *author { + if delegate || root_author == *author { return true; } @@ -368,7 +448,9 @@ where } // The proof, too, has to travel with the op. - proof.is_some_and(|proof| parents.contains(&proof) && assigned(&card.oid, &proof, author, repo)) + proof.is_some_and(|proof| { + parents.contains(&proof) && assigned(&card.oid, &proof, author, repo, memo) + }) } /// The identity commit that `op` names, if it's one of the op's git parents. @@ -383,7 +465,7 @@ fn linked_identity(op: Oid, identity: Option, repo: &R) /// Whether `proof` is an operation of `issue` in which a delegate assigned /// `author`. -fn assigned(issue: &Oid, proof: &Oid, author: &ActorId, repo: &R) -> bool +fn assigned(issue: &Oid, proof: &Oid, author: &ActorId, repo: &R, memo: &mut Memo) -> bool where R: ReadRepository + cob::change::Storage< @@ -405,10 +487,9 @@ where if proof != issue && !matches!(repo.is_ancestor_of(*issue, *proof), Ok(true)) { return false; } - if linked_identity(*proof, op.identity, repo).is_none() { - return false; - } - let Ok(Some(doc)) = op.identity_doc(repo) else { + let Some(doc) = + linked_identity(*proof, op.identity, repo).and_then(|head| memo.doc(head, repo)) + else { return false; }; if !doc.is_delegate(&Did::from(op.author)) { @@ -437,6 +518,23 @@ impl store::Cob for Board { return Err(Error::MissingIdentity); } let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?; + Board::init(op, &doc) + } + + fn op<'a, R: ReadRepository, I: IntoIterator>( + &mut self, + _op: Op, + _concurrent: I, + _repo: &R, + ) -> Result<(), Error> { + // Never called: `store::Store` requires `Cob`, but boards are evaluated + // through `Evaluate`, whose repository can load the linked objects. + Err(Error::Invalid("boards are evaluated through `Evaluate`")) + } +} + +impl Board { + fn init(op: Op, doc: &DocAt) -> Result { if !doc.is_delegate(&Did::from(op.author)) { return Err(Error::NotDelegate); } @@ -461,45 +559,16 @@ impl store::Cob for Board { Ok(board) } - fn op<'a, R: ReadRepository, I: IntoIterator>( - &mut self, - _op: Op, - _concurrent: I, - _repo: &R, - ) -> Result<(), Error> { - // Never called: `store::Store` requires `Cob`, but boards are evaluated - // through `Evaluate`, whose repository can load the linked objects. - Err(Error::Invalid("boards are evaluated through `Evaluate`")) - } -} - -impl Evaluate for Board -where - R: ReadRepository - + cob::change::Storage< - ObjectId = Oid, - Parent = Oid, - PublicKey = crypto::PublicKey, - Signature = crypto::Signature, - >, -{ - type Error = Error; - - fn init(entry: &cob::Entry, repo: &R) -> Result { - // Checked before decoding, as a newer format's actions may not decode. - if !supported(entry.manifest.version) { - return Err(Error::Version(entry.manifest.version.into())); - } - let op = Op::try_from(entry)?; - ::from_root(op, repo) - } - - fn apply<'a, I: Iterator>( - &mut self, - entry: &cob::Entry, - _concurrent: I, - repo: &R, - ) -> Result<(), Error> { + fn apply_entry(&mut self, entry: &cob::Entry, repo: &R, memo: &mut Memo) -> Result<(), Error> + where + R: ReadRepository + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, + { if !supported(entry.manifest.version) { log::debug!(target: "board", "Ignoring operation {} in a newer format", entry.id); return Ok(()); @@ -520,19 +589,15 @@ where return Ok(()); } }; - if !op.identity.is_some_and(|head| parents.contains(&head)) { + let Some(doc) = op + .identity + .filter(|head| parents.contains(head)) + .and_then(|head| memo.doc(head, repo)) + else { return Ok(()); - } - let doc = match op.identity_doc(repo) { - Ok(Some(doc)) => doc, - Ok(None) => return Ok(()), - Err(e) => { - log::warn!(target: "board", "Ignoring operation {} without an identity: {e}", op.id); - return Ok(()); - } }; for action in op.actions { - if authorized(self, &action, &op.author, &parents, &doc, repo) { + if authorized(self, &action, &op.author, &parents, &doc, repo, memo) { self.apply_action(action, op.author, op.timestamp); } else { log::debug!(target: "board", "Ignoring unauthorized action in {}", op.id); @@ -543,8 +608,122 @@ where } } +fn init_entry(entry: &cob::Entry, repo: &R) -> Result<(Board, DocAt), Error> { + // Checked before decoding, as a newer format's actions may not decode. + if !supported(entry.manifest.version) { + return Err(Error::Version(entry.manifest.version.into())); + } + let op = Op::try_from(entry)?; + if linked_identity(op.id, op.identity, repo).is_none() { + return Err(Error::MissingIdentity); + } + let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?; + let board = Board::init(op, &doc)?; + + Ok((board, doc)) +} + +/// The evaluator heartwood uses when it writes an operation. It loads every +/// linked object afresh; reads go through [`Memoised`]. +impl Evaluate for Board +where + R: ReadRepository + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, +{ + type Error = Error; + + fn init(entry: &cob::Entry, repo: &R) -> Result { + init_entry(entry, repo).map(|(board, _)| board) + } + + fn apply<'a, I: Iterator>( + &mut self, + entry: &cob::Entry, + _concurrent: I, + repo: &R, + ) -> Result<(), Error> { + self.apply_entry(entry, repo, &mut Memo::default()) + } +} + +/// A board under evaluation, loading each identity document and card root +/// once. It reaches the same state as [`Board`]'s own evaluation. +struct Memoised { + board: Board, + memo: Memo, +} + +impl Evaluate for Memoised +where + R: ReadRepository + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, +{ + type Error = Error; + + fn init(entry: &cob::Entry, repo: &R) -> Result { + let (board, doc) = init_entry(entry, repo)?; + let mut memo = Memo::default(); + memo.docs.insert(doc.commit, Some(Rc::new(doc))); + + Ok(Self { board, memo }) + } + + fn apply<'a, I: Iterator>( + &mut self, + entry: &cob::Entry, + _concurrent: I, + repo: &R, + ) -> Result<(), Error> { + self.board.apply_entry(entry, repo, &mut self.memo) + } +} + +/// The tips of every board's references, by board. +fn tips>(repo: &R) -> Result>, store::Error> { + let mut tips: BTreeMap> = BTreeMap::new(); + let refs = repo + .as_ref() + .backend + .references_glob(&REFS) + .map_err(store::Error::Git)?; + for reference in refs { + let reference = reference.map_err(store::Error::Git)?; + let (Ok(name), Some(oid)) = (reference.name(), reference.target()) else { + continue; + }; + let Ok(name) = RefString::try_from(name) else { + continue; + }; + if let Some((_, id)) = cob::object::parse_refstr(&name) { + tips.entry(id).or_default().push(oid.into()); + } + } + + Ok(tips) +} + +#[cfg(feature = "sqlite")] +fn tips_of(repo: &R, id: &BoardId) -> Result, store::Error> { + let pattern = radicle::git::refs::storage::cobs(&TYPENAME, id); + let refs = repo.references_glob(&pattern).map_err(store::Error::Git)?; + + Ok(refs.into_iter().map(|(_, oid)| oid).collect()) +} + pub struct Boards<'a, R, A> { raw: store::Store<'a, Board, R, A>, + #[cfg(feature = "sqlite")] + cache: Option, } impl<'a, R, A> Deref for Boards<'a, R, A> { @@ -564,7 +743,11 @@ where let identity = repository.identity_head()?; let raw = store::Store::open_for(&TYPENAME, repository, access)?.identity(identity); - Ok(Self { raw }) + Ok(Self { + raw, + #[cfg(feature = "sqlite")] + cache: None, + }) } /// Whether `card` may go on this repository's boards. A move writes the @@ -586,11 +769,30 @@ where } Ok(()) } + + /// Opens the boards with a cache of evaluated boards at `path`, which + /// serves reads whose board hasn't changed and takes our own writes. If + /// the cache can't be opened, reads evaluate from git. + #[cfg(feature = "sqlite")] + pub fn open_cached( + repository: &'a R, + access: A, + path: impl AsRef, + ) -> Result { + let mut boards = Self::open(repository, access)?; + match cache::Store::open(path) { + Ok(cache) => boards.cache = Some(cache), + Err(e) => log::warn!(target: "board", "Board cache disabled: {e}"), + } + + Ok(boards) + } } impl<'a, R, A> Boards<'a, R, A> where R: ReadRepository + + AsRef + cob::Store + cob::change::Storage< ObjectId = Oid, @@ -601,11 +803,56 @@ where A: access::Access + 'a, { pub fn get(&self, id: &BoardId) -> Result, store::Error> { - self.raw.get(id) + #[cfg(feature = "sqlite")] + if let Some(cache) = &self.cache { + match self.cached_get(cache, id) { + Ok(board) => return Ok(board), + Err(CacheError::Store(e)) => return Err(e), + Err(CacheError::Cache(e)) => { + log::warn!(target: "board", "Board cache failed, evaluating from git: {e}"); + } + } + } + self.evaluate(id) } pub fn list(&self) -> Result, store::Error> { - self.raw.all()?.collect() + #[cfg(feature = "sqlite")] + if let Some(cache) = &self.cache { + match self.cached_list(cache) { + Ok(boards) => return Ok(boards), + Err(CacheError::Store(e)) => return Err(e), + Err(CacheError::Cache(e)) => { + log::warn!(target: "board", "Board cache failed, evaluating from git: {e}"); + } + } + } + let mut boards = Vec::new(); + for id in tips(self.raw.as_ref())?.keys() { + if let Some(board) = self.evaluate_or_skip(id) { + boards.push((*id, board)); + } + } + + Ok(boards) + } + + fn evaluate(&self, id: &BoardId) -> Result, store::Error> { + let cob = cob::get::(self.raw.as_ref(), &TYPENAME, id)?; + + Ok(cob.map(|cob| cob.object.board)) + } + + /// A board that fails to evaluate is left out, as heartwood's listing + /// leaves out such objects. + fn evaluate_or_skip(&self, id: &BoardId) -> Option { + match self.evaluate(id) { + Ok(board) => board, + Err(e) => { + log::debug!(target: "board", "Leaving out board {id} that doesn't evaluate: {e}"); + None + } + } } /// Whether `author` may carry out `action` on `board` as of the @@ -626,6 +873,7 @@ where &action.parents(), &doc, repo, + &mut Memo::default(), )) } @@ -649,10 +897,125 @@ where } } +#[cfg(feature = "sqlite")] +enum CacheError { + Store(store::Error), + Cache(cache::Error), +} + +#[cfg(feature = "sqlite")] +impl From for CacheError { + fn from(e: cache::Error) -> Self { + Self::Cache(e) + } +} + +/// The board of a cached row that is current and reads. +#[cfg(feature = "sqlite")] +fn fresh(row: Option, token: &str) -> Option { + let row = row?; + if row.head != token { + return None; + } + serde_json::from_str(&row.board).ok() +} + +#[cfg(feature = "sqlite")] +impl<'a, R, A> Boards<'a, R, A> +where + R: ReadRepository + + AsRef + + cob::Store + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, + A: access::Access + 'a, +{ + fn cached_get(&self, cache: &cache::Store, id: &BoardId) -> Result, CacheError> { + let repo = self.raw.as_ref(); + let rid = repo.id(); + let tips = tips_of(repo, id).map_err(CacheError::Store)?; + if tips.is_empty() { + cache.remove(&rid, id)?; + return Ok(None); + } + let token = cache::head_token(&tips); + if let Some(board) = fresh(cache.get(&rid, id)?, &token) { + return Ok(Some(board)); + } + let board = self.evaluate(id).map_err(CacheError::Store)?; + match &board { + Some(board) => cache.update(&rid, id, &token, board)?, + None => cache.remove(&rid, id)?, + } + + Ok(board) + } + + fn cached_list(&self, cache: &cache::Store) -> Result, CacheError> { + let repo = self.raw.as_ref(); + let rid = repo.id(); + let current = tips(repo).map_err(CacheError::Store)?; + let mut rows: HashMap = cache.rows(&rid)?.into_iter().collect(); + let mut boards = Vec::new(); + for (id, tips) in ¤t { + let token = cache::head_token(tips); + if let Some(board) = fresh(rows.remove(id), &token) { + boards.push((*id, board)); + continue; + } + match self.evaluate_or_skip(id) { + Some(board) => { + cache.update(&rid, id, &token, &board)?; + boards.push((*id, board)); + } + None => cache.remove(&rid, id)?, + } + } + for id in rows.keys() { + cache.remove(&rid, id)?; + } + + Ok(boards) + } + + /// Stores a board we wrote, unless another namespace's tip changed in the + /// meantime: then the next read evaluates it with that change. + fn remember(&self, id: &BoardId, before: &[Oid], head: Oid, board: &Board) { + let Some(cache) = &self.cache else { + return; + }; + let repo = self.raw.as_ref(); + let result = tips_of(repo, id) + .map_err(CacheError::Store) + .and_then(|after| { + let expected = after.contains(&head) + && after.iter().all(|tip| *tip == head || before.contains(tip)); + if !expected { + return Ok(()); + } + Ok(cache.update(&repo.id(), id, &cache::head_token(&after), board)?) + }); + match result { + Ok(()) => {} + Err(CacheError::Store(e)) => { + log::warn!(target: "board", "Board cache write skipped: {e}"); + } + Err(CacheError::Cache(e)) => { + log::warn!(target: "board", "Board cache write failed: {e}"); + } + } + } +} + impl<'a, R, S> Boards<'a, R, access::WriteAs<'a, S>> where R: WriteRepository + SignRepository + + AsRef + cob::Store + cob::change::Storage< ObjectId = Oid, @@ -663,11 +1026,15 @@ where S: crypto::Signer, { pub fn create(&mut self, name: &str) -> Result<(BoardId, Board), store::Error> { - store::Transaction::initial("Create board", &mut self.raw, |tx, _| { + let (id, board) = store::Transaction::initial("Create board", &mut self.raw, |tx, _| { tx.push(Action::Edit { name: name.to_string(), }) - }) + })?; + #[cfg(feature = "sqlite")] + self.remember(&id, &[], *id, &board); + + Ok((id, board)) } pub fn rename(&mut self, id: &BoardId, name: &str) -> Result { @@ -713,7 +1080,13 @@ where ) -> Result { let mut tx = store::Transaction::::default(); tx.extend(actions)?; - let (board, _) = tx.commit(message, *id, &mut self.raw)?; + #[cfg(feature = "sqlite")] + let before = tips_of(self.raw.as_ref(), id)?; + let (board, head) = tx.commit(message, *id, &mut self.raw)?; + #[cfg(feature = "sqlite")] + self.remember(id, &before, head, &board); + #[cfg(not(feature = "sqlite"))] + let _ = head; Ok(board) } diff --git a/desktop/crates/radicle-board/src/test.rs b/desktop/crates/radicle-board/src/test.rs index 200636221..58a181791 100644 --- a/desktop/crates/radicle-board/src/test.rs +++ b/desktop/crates/radicle-board/src/test.rs @@ -1141,6 +1141,400 @@ fn oversized_names_columns_and_positions_are_ignored() { assert!(board.placement(&card).is_none()); } +#[test] +fn memoised_and_plain_evaluation_agree() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let alices = open_issue(&repo, &alice.signer, "Alice's issue"); + let assigned = open_issue(&repo, &alice.signer, "Assigned to Bob"); + let bobs = open_issue(&repo, &bob, "Bob's issue"); + let patch = open_patch(&repo, &alice.signer, "A patch"); + let mut issues = Issues::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let proof = issues + .get_mut(&assigned, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let a = issue_card(&*repo, alices); + let s = issue_card(&*repo, assigned); + let b = issue_card(&*repo, bobs); + let p = patch_card(&*repo, patch); + move_card( + &alice.storage, + &mut boards, + &id, + a.clone(), + "todo", + first(), + None, + ); + move_card( + &alice.storage, + &mut boards, + &id, + s.clone(), + "todo", + first(), + None, + ); + move_card( + &alice.storage, + &mut boards, + &id, + p.clone(), + "in-review", + first(), + None, + ); + + let mut bobs_boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + // The same card and identity come up in allowed and denied moves, so a + // memo that kept more than the loaded objects would show here. + move_card( + &alice.storage, + &mut bobs_boards, + &id, + b.clone(), + "in-progress", + first(), + None, + ); + move_card( + &alice.storage, + &mut bobs_boards, + &id, + a.clone(), + "done", + first(), + None, + ); + move_card( + &alice.storage, + &mut bobs_boards, + &id, + s.clone(), + "done", + first(), + Some(proof), + ); + move_card( + &alice.storage, + &mut bobs_boards, + &id, + s.clone(), + "in-progress", + first(), + Some(proof), + ); + move_card( + &alice.storage, + &mut bobs_boards, + &id, + p.clone(), + "done", + first(), + None, + ); + move_card( + &alice.storage, + &mut bobs_boards, + &id, + a.clone(), + "backlog", + first(), + Some(proof), + ); + move_card( + &alice.storage, + &mut boards, + &id, + a.clone(), + "canceled", + first(), + None, + ); + bobs_boards + .update( + &alice.storage, + &id, + vec![Action::Remove { card: p.clone() }], + ) + .unwrap(); + + let plain = radicle::cob::get::(&*repo, &crate::TYPENAME, &id) + .unwrap() + .unwrap() + .object; + let memoised = Boards::open(&*repo, ReadOnly) + .unwrap() + .get(&id) + .unwrap() + .unwrap(); + + assert_eq!(memoised, plain); + assert_eq!(plain.placement(&a).unwrap().column, "canceled"); + assert_eq!(plain.placement(&s).unwrap().column, "in-progress"); + assert_eq!(plain.placement(&b).unwrap().column, "in-progress"); + assert_eq!(plain.placement(&p).unwrap().column, "in-review"); +} + +#[cfg(feature = "sqlite")] +mod cache { + use std::str::FromStr; + + use radicle::cob::store::access::{ReadOnly, WriteAs}; + use radicle::crypto::SigningKey; + use radicle::storage::ReadRepository; + use radicle::test; + + use super::{first, issue_card, move_card, open_issue}; + use crate::cache::{Store, head_token}; + use crate::{Board, BoardId, Boards, REFS}; + + type Repo = radicle::storage::git::Repository; + + fn tips(repo: &Repo) -> Vec { + repo.backend + .references_glob(&REFS) + .unwrap() + .map(|r| r.unwrap().target().unwrap().into()) + .collect() + } + + /// Stores a board named "Tampered" under `token`, to tell a read served + /// from the cache from one that evaluated. + fn tamper(path: &std::path::Path, repo: &Repo, id: &BoardId, token: &str) -> Board { + let board = Boards::open(repo, ReadOnly).unwrap().get(id).unwrap(); + let mut json = serde_json::to_value(board).unwrap(); + json["name"] = "Tampered".into(); + let tampered: Board = serde_json::from_value(json).unwrap(); + assert_eq!(tampered.name(), "Tampered"); + Store::open(path) + .unwrap() + .update(&repo.id(), id, token, &tampered) + .unwrap(); + tampered + } + + fn stored(path: &std::path::Path, repo: &Repo, id: &BoardId) -> Option<(String, Board)> { + let row = Store::open(path).unwrap().get(&repo.id(), id).unwrap()?; + Some((row.head, serde_json::from_str(&row.board).unwrap())) + } + + #[test] + fn a_hit_is_the_evaluated_board() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let path = alice.root.join("boards.db"); + let issue = open_issue(&repo, &alice.signer, "Issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + move_card( + &alice.storage, + &mut boards, + &id, + issue_card(&*repo, issue), + "todo", + first(), + None, + ); + let plain = Boards::open(&*repo, ReadOnly).unwrap(); + + let cached = Boards::open_cached(&*repo, ReadOnly, &path).unwrap(); + assert_eq!(cached.get(&id).unwrap(), plain.get(&id).unwrap()); + assert_eq!(cached.list().unwrap(), plain.list().unwrap()); + let (head, board) = stored(&path, &repo, &id).unwrap(); + assert_eq!(head, head_token(&tips(&repo))); + assert_eq!(Some(board), plain.get(&id).unwrap()); + + let tampered = tamper(&path, &repo, &id, &head_token(&tips(&repo))); + assert_eq!(cached.get(&id).unwrap().unwrap(), tampered); + assert_eq!(cached.list().unwrap(), vec![(id, tampered)]); + } + + #[test] + fn a_new_operation_in_any_namespace_is_evaluated() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let path = alice.root.join("boards.db"); + let bob = SigningKey::mock(1); + let bobs = open_issue(&repo, &bob, "Bob's issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let cached = Boards::open_cached(&*repo, ReadOnly, &path).unwrap(); + cached.get(&id).unwrap(); + + // Bob's reference appears as it would after a fetch of his namespace. + tamper(&path, &repo, &id, &head_token(&tips(&repo))); + let mut bobs_boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, bobs); + let written = move_card( + &alice.storage, + &mut bobs_boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert_eq!(tips(&repo).len(), 2); + let board = cached.get(&id).unwrap().unwrap(); + assert_eq!(board, written); + assert_eq!(board.placement(&card).unwrap().column, "todo"); + assert_eq!(cached.list().unwrap(), vec![(id, written)]); + + tamper(&path, &repo, &id, &head_token(&tips(&repo))); + let written = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "done", + first(), + None, + ); + assert_eq!(cached.get(&id).unwrap().unwrap(), written); + assert_eq!(cached.list().unwrap(), vec![(id, written)]); + } + + #[test] + fn a_deleted_board_is_pruned() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let path = alice.root.join("boards.db"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let (other, _) = boards.create("Other").unwrap(); + let cached = Boards::open_cached(&*repo, ReadOnly, &path).unwrap(); + assert_eq!(cached.list().unwrap().len(), 2); + assert!(stored(&path, &repo, &id).is_some()); + + for reference in repo.backend.references_glob(&REFS).unwrap() { + let mut reference = reference.unwrap(); + if reference.name().unwrap().ends_with(&id.to_string()) { + reference.delete().unwrap(); + } + } + + let listed = cached.list().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].0, other); + assert!(stored(&path, &repo, &id).is_none()); + + tamper(&path, &repo, &other, &head_token(&tips(&repo))); + for reference in repo.backend.references_glob(&REFS).unwrap() { + reference.unwrap().delete().unwrap(); + } + assert_eq!(cached.get(&other).unwrap(), None); + assert!(stored(&path, &repo, &other).is_none()); + } + + #[test] + fn a_format_change_invalidates() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let path = alice.root.join("boards.db"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let cached = Boards::open_cached(&*repo, ReadOnly, &path).unwrap(); + let token = head_token(&tips(&repo)); + let (format, tips) = token.split_once(':').unwrap(); + assert_eq!(format, format!("v{}.{}", 5, crate::VERSION)); + + for stale in [ + format!("v4.{}:{tips}", crate::VERSION), + format!("v5.0:{tips}"), + ] { + tamper(&path, &repo, &id, &stale); + assert_eq!(cached.get(&id).unwrap().unwrap().name(), "Roadmap"); + assert_eq!(stored(&path, &repo, &id).unwrap().0, token); + tamper(&path, &repo, &id, &stale); + assert_eq!(cached.list().unwrap()[0].1.name(), "Roadmap"); + assert_eq!(stored(&path, &repo, &id).unwrap().0, token); + } + } + + #[test] + fn a_broken_cache_falls_back_to_evaluation() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let path = alice.root.join("boards.db"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, board) = boards.create("Roadmap").unwrap(); + + std::fs::write(&path, "not a database").unwrap(); + let corrupt = Boards::open_cached(&*repo, ReadOnly, &path).unwrap(); + assert_eq!(corrupt.get(&id).unwrap().unwrap(), board); + assert_eq!(corrupt.list().unwrap(), vec![(id, board.clone())]); + + std::fs::remove_file(&path).unwrap(); + let cached = Boards::open_cached(&*repo, ReadOnly, &path).unwrap(); + assert_eq!(cached.get(&id).unwrap().unwrap(), board); + sqlite::open(&path) + .unwrap() + .execute("DROP TABLE boards") + .unwrap(); + assert_eq!(cached.get(&id).unwrap().unwrap(), board); + assert_eq!(cached.list().unwrap(), vec![(id, board)]); + } + + #[test] + fn writes_store_the_written_board() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let path = alice.root.join("boards.db"); + let issue = open_issue(&repo, &alice.signer, "Issue"); + let mut boards = Boards::open_cached(&*repo, WriteAs::new(&alice.signer), &path).unwrap(); + + let (id, created) = boards.create("Roadmap").unwrap(); + assert_eq!( + stored(&path, &repo, &id).unwrap(), + (head_token(&tips(&repo)), created) + ); + + let card = issue_card(&*repo, issue); + let moved = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert_eq!( + stored(&path, &repo, &id).unwrap(), + (head_token(&tips(&repo)), moved) + ); + let renamed = boards.rename(&id, "Plan").unwrap(); + assert_eq!(stored(&path, &repo, &id).unwrap().1, renamed); + let columns = boards + .set_columns(&id, vec![crate::Column::new("one", "One", None)]) + .unwrap(); + let (head, stored_board) = stored(&path, &repo, &id).unwrap(); + assert_eq!(stored_board, columns); + assert_eq!(head, head_token(&tips(&repo))); + assert_eq!(stored_board.name(), "Plan"); + assert_eq!(stored_board.columns().len(), 1); + + // Served from the cache: the row has the current token. + let tampered = tamper(&path, &repo, &id, &head); + assert_eq!(boards.get(&id).unwrap().unwrap(), tampered); + let _ = BoardId::from_str(&id.to_string()).unwrap(); + } +} + #[test] fn moves_that_do_not_link_their_local_card_are_ignored() { let test::setup::NodeWithRepo { diff --git a/desktop/crates/radicle-types/Cargo.toml b/desktop/crates/radicle-types/Cargo.toml index 47cf5b7e5..79ca7a5ae 100644 --- a/desktop/crates/radicle-types/Cargo.toml +++ b/desktop/crates/radicle-types/Cargo.toml @@ -18,7 +18,7 @@ radicle = { version = "0.25", features = ["tor", "i2p"] } radicle-artifact = { version = "0.23" } radicle-artifact-client = { version = "0.23" } radicle-artifact-core = { version = "0.23" } -radicle-board = { path = "../radicle-board" } +radicle-board = { path = "../radicle-board", features = ["sqlite"] } radicle-job = { version = "0.6" } radicle-localtime = { version = "0.1.0", features = ["serde"] } radicle-surf = { version = "0.29.0", features = ["serde"] } diff --git a/desktop/crates/radicle-types/src/traits/board.rs b/desktop/crates/radicle-types/src/traits/board.rs index 043d2241d..0b489dc05 100644 --- a/desktop/crates/radicle-types/src/traits/board.rs +++ b/desktop/crates/radicle-types/src/traits/board.rs @@ -9,7 +9,8 @@ use radicle::node::{Handle, Node}; use radicle::patch::cache::Patches as _; use radicle::storage::{ReadRepository, ReadStorage}; use radicle_board::{ - Boards as BoardStore, MAX_COLUMN_ID_LEN, MAX_NAME_LEN, MAX_POSITION_LEN, position, + Boards as BoardStore, MAX_COLUMN_ID_LEN, MAX_NAME_LEN, MAX_POSITION_LEN, cache_db_path, + position, }; use crate::cobs; @@ -22,7 +23,7 @@ pub trait Boards: Profile { let profile = self.profile(); let repo = profile.storage.repository(rid)?; let aliases = profile.aliases(); - let boards = BoardStore::open(&repo, ReadOnly)?; + let boards = BoardStore::open_cached(&repo, ReadOnly, cache_db_path(profile.cobs()))?; let mut boards = boards .list()? @@ -55,7 +56,8 @@ pub trait Boards: Profile { } let signer = profile.signer()?; let aliases = profile.aliases(); - let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let mut boards = + BoardStore::open_cached(&repo, WriteAs::new(&signer), cache_db_path(profile.cobs()))?; let (id, board) = boards.create(name)?; announce(&profile, rid, &opts); @@ -79,7 +81,8 @@ pub trait Boards: Profile { let aliases = profile.aliases(); let me = profile.public_key; - let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let mut boards = + BoardStore::open_cached(&repo, WriteAs::new(&signer), cache_db_path(profile.cobs()))?; let current = boards .get(&board)? .ok_or(Error::Board("the board doesn't exist"))?; @@ -189,7 +192,8 @@ pub trait Boards: Profile { let repo = profile.storage.repository(rid)?; let signer = profile.signer()?; let aliases = profile.aliases(); - let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let mut boards = + BoardStore::open_cached(&repo, WriteAs::new(&signer), cache_db_path(profile.cobs()))?; let current = boards .get(&board)? .ok_or(Error::Board("the board doesn't exist"))?; @@ -222,7 +226,8 @@ pub trait Boards: Profile { let repo = profile.storage.repository(rid)?; let signer = profile.signer()?; let aliases = profile.aliases(); - let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let mut boards = + BoardStore::open_cached(&repo, WriteAs::new(&signer), cache_db_path(profile.cobs()))?; let current = boards .get(&board)? .ok_or(Error::Board("the board doesn't exist"))?; @@ -304,7 +309,7 @@ fn announce(profile: &radicle::Profile, rid: RepoId, opts: &cobs::CobOptions) { mod test { use radicle::cob::cache::NoCache; use radicle::cob::issue::Issues; - use radicle::cob::store::access::WriteAs; + use radicle::cob::store::access::{ReadOnly, WriteAs}; use radicle::cob::{ObjectId, Title}; use radicle::crypto::{Seed, Signer as _, SigningKey}; use radicle::identity::{Did, RepoId, Visibility}; @@ -624,4 +629,65 @@ mod test { assert_eq!(links[0].patch, patch); assert_eq!(links[0].issues, vec![issue]); } + + #[test] + fn boards_are_read_through_the_cache() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup(tmp.path(), Network::Test); + let a = open_issue(&state, rid, "Mine"); + let bobs = open_issue_as(&state, rid, "Bob's", OTHER_SEED); + let board = state.create_board(rid, "Roadmap".into(), quiet()).unwrap(); + let db = radicle_board::cache_db_path(state.profile.cobs()); + assert!(db.exists()); + + let update = state + .update_board(rid, board.id, vec![moved(&a, "todo", "V")], vec![], quiet()) + .unwrap(); + let listed = state.list_boards(rid).unwrap(); + assert_eq!(listed, vec![update.board.clone()]); + + // A row with the current token is served as is. + let repo = state.profile.storage.repository(rid).unwrap(); + let tips: Vec = repo + .backend + .references_glob(&radicle_board::REFS) + .unwrap() + .map(|r| r.unwrap().target().unwrap().into()) + .collect(); + let mut tampered = serde_json::to_value( + radicle_board::Boards::open(&repo, ReadOnly) + .unwrap() + .get(&board.id) + .unwrap(), + ) + .unwrap(); + tampered["name"] = "Tampered".into(); + radicle_board::cache::Store::open(&db) + .unwrap() + .update( + &rid, + &board.id, + &radicle_board::cache::head_token(&tips), + &serde_json::from_value(tampered).unwrap(), + ) + .unwrap(); + assert_eq!(state.list_boards(rid).unwrap()[0].name, "Tampered"); + + // Bob's move arrives in his namespace, as after a fetch. + let bob = SigningKey::from_seed(Seed::new(OTHER_SEED)); + let mut boards = radicle_board::Boards::open(&repo, WriteAs::new(&bob)).unwrap(); + let action = boards.move_action(bobs.clone().into(), "todo".into(), "W".into(), None); + boards + .update(&state.profile.storage, &board.id, vec![action]) + .unwrap(); + + let listed = state.list_boards(rid).unwrap(); + assert_eq!(listed[0].name, "Roadmap"); + assert_eq!(listed[0].cards.len(), 2); + assert_eq!(listed[0].cards[1].card, bobs); + let renamed = state + .rename_board(rid, board.id, "Plan".into(), quiet()) + .unwrap(); + assert_eq!(state.list_boards(rid).unwrap(), vec![renamed]); + } } diff --git a/desktop/docs/adr/0005-boards.md b/desktop/docs/adr/0005-boards.md index 85a1c1512..4f6b7cddc 100644 --- a/desktop/docs/adr/0005-boards.md +++ b/desktop/docs/adr/0005-boards.md @@ -119,7 +119,27 @@ other tools can read it. `radicle`. The type name is `works.monadic.board`; the op manifest carries the format version, and ops in a newer format are ignored. -**Changes are batched.** The app waits until nothing has changed for 2.5 seconds +**Evaluated boards are cached.** Heartwood caches only its own object types, so +without a cache every read replays every board's history. The app keeps +evaluated boards in `cobs/boards.db` in the profile, next to heartwood's caches. +The cache is: + +- **Never stale.** Each row is keyed by the board's tips across all namespaces + and the format versions. Every read compares them with the references in git, + so ops the node fetched in the background are picked up on the next read. +- **Exact.** A board's state depends only on its tips, because an op's commit + has everything it checks (identity document, the card's first op, the proof) + as parents. A row with matching tips is the board. +- **Rebuilt whole, never patched.** A fetched op can be concurrent with ones + already applied and sort before them, so a stale board is evaluated again from + the start. +- **Disposable.** Boards whose references are gone are pruned, our own writes + store their result, and on any cache error the app evaluates from git. + +**Changes are batched and debounced.** Every op is a signed commit that every +peer fetches and keeps, and that every evaluation replays, so one op per drop +would grow the board's history with every small rearrangement, including moves +undone a moment later. The app waits until nothing has changed for 2.5 seconds and writes all pending changes as one op, dropping changes that cancel out. Pending changes are written when leaving or switching boards, and on quitting, which writes without announcing: peers learn of those changes with the