mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
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:
Generated
+1
@@ -4635,6 +4635,7 @@ dependencies = [
|
||||
"radicle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlite",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ¤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<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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user