desktop: Cache evaluated boards

Heartwood doesn't cache custom COBs, so every board read replayed the
board's whole history, about 2.5 s for 3,000 ops. Evaluated boards are
now kept in `boards.db` next to radicle-artifact's cache, keyed by the
board's ref tips, so a read with nothing new takes a few milliseconds and
an op fetched from any peer still triggers a fresh evaluation.
This commit is contained in:
Rūdolfs Ošiņš
2026-11-06 18:21:33 +01:00
parent 682a406d85
commit 1386f65557
9 changed files with 1407 additions and 77 deletions
+1
View File
@@ -4635,6 +4635,7 @@ dependencies = [
"radicle",
"serde",
"serde_json",
"sqlite",
"thiserror 2.0.19",
]
+4
View File
@@ -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"]
@@ -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<Oid>,
actions: Vec<Action>,
) -> 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::<Board, _>(&*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));
}
+201
View File
@@ -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<Path>) -> PathBuf {
cobs.as_ref().join(DB_FILE)
}
/// The freshness token of a board at `tips`.
pub fn head_token<'a>(tips: impl IntoIterator<Item = &'a Oid>) -> String {
let mut tips: Vec<String> = 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<Path>) -> Result<Self, Error> {
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<Option<Row>, 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<Vec<(BoardId, Row)>, 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<T>(
db: &sql::ConnectionThreadSafe,
f: impl FnOnce(&sql::ConnectionThreadSafe) -> Result<T, Error>,
) -> Result<T, Error> {
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<usize, Error> {
let version = db
.prepare("PRAGMA user_version")?
.into_iter()
.next()
.ok_or(Error::NoRows)??
.read::<i64, _>(0);
Ok(version as usize)
}
+441 -68
View File
@@ -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<TypeName> =
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<String> =
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<Column>,
#[serde(with = "cards")]
cards: BTreeMap<Card, Placement>,
}
/// 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<S: Serializer>(
cards: &BTreeMap<Card, Placement>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.collect_seq(cards)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<Card, Placement>, 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<Oid, Option<Rc<DocAt>>>,
roots: HashMap<ObjectId, Option<(ActorId, TypeName)>>,
}
impl Memo {
fn doc<R: ReadRepository>(&mut self, head: Oid, repo: &R) -> Option<Rc<DocAt>> {
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<R>(&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<R>(
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<R: ReadRepository>(op: Oid, identity: Option<Oid>, repo: &R)
/// Whether `proof` is an operation of `issue` in which a delegate assigned
/// `author`.
fn assigned<R>(issue: &Oid, proof: &Oid, author: &ActorId, repo: &R) -> bool
fn assigned<R>(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<Item = &'a cob::Entry>>(
&mut self,
_op: Op<Action>,
_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<Action>, doc: &DocAt) -> Result<Self, Error> {
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<Item = &'a cob::Entry>>(
&mut self,
_op: Op<Action>,
_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<R> Evaluate<R> 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<Self, 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)?;
<Board as store::Cob>::from_root(op, repo)
}
fn apply<'a, I: Iterator<Item = (&'a cob::EntryId, &'a cob::Entry)>>(
&mut self,
entry: &cob::Entry,
_concurrent: I,
repo: &R,
) -> Result<(), Error> {
fn apply_entry<R>(&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<R: ReadRepository>(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<R> Evaluate<R> 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<Self, Error> {
init_entry(entry, repo).map(|(board, _)| board)
}
fn apply<'a, I: Iterator<Item = (&'a cob::EntryId, &'a cob::Entry)>>(
&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<R> Evaluate<R> 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<Self, Error> {
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<Item = (&'a cob::EntryId, &'a cob::Entry)>>(
&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<R: AsRef<Repository>>(repo: &R) -> Result<BTreeMap<BoardId, Vec<Oid>>, store::Error> {
let mut tips: BTreeMap<BoardId, Vec<Oid>> = 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<R: ReadRepository>(repo: &R, id: &BoardId) -> Result<Vec<Oid>, 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<cache::Store>,
}
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<std::path::Path>,
) -> Result<Self, RepositoryError> {
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<Repository>
+ cob::Store<Namespace = NodeId>
+ cob::change::Storage<
ObjectId = Oid,
@@ -601,11 +803,56 @@ where
A: access::Access + 'a,
{
pub fn get(&self, id: &BoardId) -> Result<Option<Board>, 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<Vec<(BoardId, Board)>, 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<Option<Board>, store::Error> {
let cob = cob::get::<Memoised, _>(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<Board> {
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<cache::Error> 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<cache::Row>, token: &str) -> Option<Board> {
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<Repository>
+ cob::Store<Namespace = NodeId>
+ 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<Option<Board>, 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<Vec<(BoardId, Board)>, CacheError> {
let repo = self.raw.as_ref();
let rid = repo.id();
let current = tips(repo).map_err(CacheError::Store)?;
let mut rows: HashMap<BoardId, cache::Row> = cache.rows(&rid)?.into_iter().collect();
let mut boards = Vec::new();
for (id, tips) in &current {
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<Repository>
+ cob::Store<Namespace = NodeId>
+ 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<Board, store::Error> {
@@ -713,7 +1080,13 @@ where
) -> Result<Board, store::Error> {
let mut tx = store::Transaction::<Board, R>::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)
}
+394
View File
@@ -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::<Board, _>(&*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<radicle::git::Oid> {
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 {
+1 -1
View File
@@ -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"] }
@@ -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<radicle::git::Oid> = 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]);
}
}
+21 -1
View File
@@ -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