cobs: allow for batched operations

Sometimes it is more efficient to perform multiple operations at once
and store those in the collaborative object graph together.

Change the contents to be a non-empty sequence of byte sequences,
i.e. NonEmpty<Vec<u8>>. These are stored as blobs in a git tree in
order by their index in the non-empty sequence. They are, in turn,
retrieved in order of their original index.

Change the conversion of an `EntryWithClock` to return a non-empty
sequence of `Op`s, which are in turn used to apply many changes in
each `from_history` implementation.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
This commit is contained in:
Fintan Halpenny 2022-12-05 21:06:38 +00:00 committed by Alexis Sellier
parent 7c9ee0f911
commit 5f80ee9671
No known key found for this signature in database
12 changed files with 106 additions and 66 deletions

9
Cargo.lock generated
View File

@ -1659,9 +1659,9 @@ checksum = "9ff7ac1e5ea23db6d61ad103e91864675049644bf47c35912336352fa4e9c109"
[[package]] [[package]]
name = "nonempty" name = "nonempty"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f1f8e5676e1a1f2ee8b21f38238e1243c827531c9435624c7bfb305102cee4" checksum = "aeaf4ad7403de93e699c191202f017118df734d3850b01e13a3a8b2e6953d3c9"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -2079,7 +2079,7 @@ dependencies = [
"git2", "git2",
"log", "log",
"multibase", "multibase",
"nonempty 0.8.0", "nonempty 0.8.1",
"num-traits", "num-traits",
"olpc-cjson", "olpc-cjson",
"once_cell", "once_cell",
@ -2138,6 +2138,7 @@ dependencies = [
"git-trailers", "git-trailers",
"git2", "git2",
"log", "log",
"nonempty 0.8.1",
"petgraph", "petgraph",
"qcheck", "qcheck",
"qcheck-macros", "qcheck-macros",
@ -2240,7 +2241,7 @@ dependencies = [
"log", "log",
"nakamoto-net", "nakamoto-net",
"nakamoto-net-poll", "nakamoto-net-poll",
"nonempty 0.8.0", "nonempty 0.8.1",
"qcheck", "qcheck",
"qcheck-macros", "qcheck-macros",
"radicle", "radicle",

View File

@ -17,6 +17,7 @@ git-commit = { version = "0.2" }
git-ref-format = { version = "0.1" } git-ref-format = { version = "0.1" }
git-trailers = { version = "0.1" } git-trailers = { version = "0.1" }
log = { version = "0.4.17" } log = { version = "0.4.17" }
nonempty = { version = "0.8.1", features = ["serialize"] }
petgraph = { version = "0.5" } petgraph = { version = "0.5" }
radicle-git-ext = { version = "0.2" } radicle-git-ext = { version = "0.2" }
serde_json = { version = "1.0" } serde_json = { version = "1.0" }

View File

@ -3,11 +3,13 @@
// This file is part of radicle-link, distributed under the GPLv3 with Radicle // This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file. // Linking Exception. For full terms see the included LICENSE file.
use std::collections::BTreeMap;
use std::convert::TryFrom; use std::convert::TryFrom;
use git_commit::{self as commit, Commit}; use git_commit::{self as commit, Commit};
use git_ext::Oid; use git_ext::Oid;
use git_trailers::OwnedTrailer; use git_trailers::OwnedTrailer;
use nonempty::NonEmpty;
use crate::history::entry::Timestamp; use crate::history::entry::Timestamp;
use crate::{ use crate::{
@ -18,7 +20,6 @@ use crate::{
}; };
const MANIFEST_BLOB_NAME: &str = "manifest"; const MANIFEST_BLOB_NAME: &str = "manifest";
const CHANGE_BLOB_NAME: &str = "change";
pub mod error { pub mod error {
use std::str::Utf8Error; use std::str::Utf8Error;
@ -195,14 +196,23 @@ fn load_contents(
repo: &git2::Repository, repo: &git2::Repository,
tree: &git2::Tree, tree: &git2::Tree,
) -> Result<entry::Contents, error::Load> { ) -> Result<entry::Contents, error::Load> {
let contents_tree_entry = tree let ops = tree
.get_name(CHANGE_BLOB_NAME) .iter()
.ok_or_else(|| error::Load::NoChange(tree.id().into()))?; .filter_map(|entry| {
let contents_object = contents_tree_entry.to_object(repo)?; entry.kind().and_then(|kind| match kind {
let contents_blob = contents_object git2::ObjectType::Blob => {
.as_blob() let name = entry.name()?.parse::<i8>().ok()?;
.ok_or_else(|| error::Load::ChangeNotBlob(tree.id().into()))?; let content = entry.to_object(repo).and_then(|object| {
Ok(contents_blob.content().to_owned()) object.peel_to_blob().map(|blob| blob.content().to_owned())
});
Some(content.map(|c| (name, c)))
}
_ => None,
})
})
.collect::<Result<BTreeMap<_, _>, _>>()?;
NonEmpty::collect(ops.into_values()).ok_or_else(|| error::Load::NoChange(tree.id().into()))
} }
fn write_commit<O>( fn write_commit<O>(
@ -263,8 +273,10 @@ fn write_manifest(
git2::FileMode::Blob.into(), git2::FileMode::Blob.into(),
)?; )?;
let change_blob = repo.blob(contents.as_ref())?; for (ix, op) in contents.iter().enumerate() {
tb.insert(CHANGE_BLOB_NAME, change_blob, git2::FileMode::Blob.into())?; let change_blob = repo.blob(op.as_ref())?;
tb.insert(&ix.to_string(), change_blob, git2::FileMode::Blob.into())?;
}
tb.write() tb.write()
} }

View File

@ -4,14 +4,14 @@
// Linking Exception. For full terms see the included LICENSE file. // Linking Exception. For full terms see the included LICENSE file.
use git_ext::Oid; use git_ext::Oid;
use nonempty::NonEmpty;
use radicle_crypto::PublicKey; use radicle_crypto::PublicKey;
use crate::pruning_fold; use crate::pruning_fold;
/// Entry contents. /// Entry contents.
/// This is the change payload. /// This is the change payload.
pub type Contents = Vec<u8>; pub type Contents = NonEmpty<Vec<u8>>;
/// Logical clock used to track causality in change graph. /// Logical clock used to track causality in change graph.
pub type Clock = u64; pub type Clock = u64;

View File

@ -2,6 +2,7 @@ use std::ops::ControlFlow;
use crypto::test::signer::MockSigner; use crypto::test::signer::MockSigner;
use git_ref_format::{refname, Component, RefString}; use git_ref_format::{refname, Component, RefString};
use nonempty::nonempty;
use qcheck::Arbitrary; use qcheck::Arbitrary;
use radicle_crypto::Signer; use radicle_crypto::Signer;
@ -29,7 +30,7 @@ fn roundtrip() {
&proj.identifier(), &proj.identifier(),
Create { Create {
history_type: "test".to_string(), history_type: "test".to_string(),
contents: Vec::new(), contents: nonempty!(Vec::new()),
typename: typename.clone(), typename: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
}, },
@ -61,7 +62,7 @@ fn list_cobs() {
&proj.identifier(), &proj.identifier(),
Create { Create {
history_type: "test".to_string(), history_type: "test".to_string(),
contents: b"issue 1".to_vec(), contents: nonempty!(b"issue 1".to_vec()),
typename: typename.clone(), typename: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
}, },
@ -75,7 +76,7 @@ fn list_cobs() {
&proj.identifier(), &proj.identifier(),
Create { Create {
history_type: "test".to_string(), history_type: "test".to_string(),
contents: b"issue 2".to_vec(), contents: nonempty!(b"issue 2".to_vec()),
typename: typename.clone(), typename: typename.clone(),
message: "commenting xyz.rad.issue".to_string(), message: "commenting xyz.rad.issue".to_string(),
}, },
@ -109,7 +110,7 @@ fn update_cob() {
&proj.identifier(), &proj.identifier(),
Create { Create {
history_type: "test".to_string(), history_type: "test".to_string(),
contents: Vec::new(), contents: nonempty!(Vec::new()),
typename: typename.clone(), typename: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
}, },
@ -126,7 +127,7 @@ fn update_cob() {
&proj, &proj,
&proj.identifier(), &proj.identifier(),
Update { Update {
changes: b"issue 1".to_vec(), changes: nonempty!(b"issue 1".to_vec()),
history_type: "test".to_string(), history_type: "test".to_string(),
object_id: *cob.id(), object_id: *cob.id(),
typename: typename.clone(), typename: typename.clone(),
@ -166,7 +167,7 @@ fn traverse_cobs() {
&terry_proj, &terry_proj,
&terry_proj.identifier(), &terry_proj.identifier(),
Create { Create {
contents: b"issue 1".to_vec(), contents: nonempty!(b"issue 1".to_vec()),
history_type: "test".to_string(), history_type: "test".to_string(),
typename: typename.clone(), typename: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
@ -188,7 +189,7 @@ fn traverse_cobs() {
&neil_proj, &neil_proj,
&neil_proj.identifier(), &neil_proj.identifier(),
Update { Update {
changes: b"issue 2".to_vec(), changes: nonempty!(b"issue 2".to_vec()),
history_type: "test".to_string(), history_type: "test".to_string(),
object_id: *cob.id(), object_id: *cob.id(),
typename, typename,
@ -200,7 +201,7 @@ fn traverse_cobs() {
// traverse over the history and filter by changes that were only authorized by terry // traverse over the history and filter by changes that were only authorized by terry
let contents = updated.history().traverse(Vec::new(), |mut acc, entry| { let contents = updated.history().traverse(Vec::new(), |mut acc, entry| {
if entry.actor() == terry_signer.public_key() { if entry.actor() == terry_signer.public_key() {
acc.push(entry.contents().to_vec()); acc.push(entry.contents().head.to_vec());
} }
ControlFlow::Continue(acc) ControlFlow::Continue(acc)
}); });
@ -209,7 +210,7 @@ fn traverse_cobs() {
// traverse over the history and filter by changes that were only authorized by neil // traverse over the history and filter by changes that were only authorized by neil
let contents = updated.history().traverse(Vec::new(), |mut acc, entry| { let contents = updated.history().traverse(Vec::new(), |mut acc, entry| {
acc.push(entry.contents().to_vec()); acc.push(entry.contents().head.to_vec());
ControlFlow::Continue(acc) ControlFlow::Continue(acc)
}); });

View File

@ -21,7 +21,7 @@ lexopt = { version = "0.2.1" }
log = { version = "0.4.17", features = ["std"] } log = { version = "0.4.17", features = ["std"] }
nakamoto-net = { version = "0.3.0" } nakamoto-net = { version = "0.3.0" }
nakamoto-net-poll = { version = "0.3.0" } nakamoto-net-poll = { version = "0.3.0" }
nonempty = { version = "0.8.0", features = ["serialize"] } nonempty = { version = "0.8.1", features = ["serialize"] }
qcheck = { version = "1", default-features = false, optional = true } qcheck = { version = "1", default-features = false, optional = true }
sqlite = { version = "0.30.3" } sqlite = { version = "0.30.3" }
sqlite3-src = { version = "0.4.0", features = ["bundled"] } # Ensures static linking sqlite3-src = { version = "0.4.0", features = ["bundled"] } # Ensures static linking

View File

@ -21,6 +21,7 @@ git-ref-format = { version = "0", features = ["serde", "macro"] }
multibase = { version = "0.9.1" } multibase = { version = "0.9.1" }
num-traits = { version = "0.2.15", default-features = false, features = ["std"] } num-traits = { version = "0.2.15", default-features = false, features = ["std"] }
log = { version = "0.4.17", features = ["std"] } log = { version = "0.4.17", features = ["std"] }
nonempty = { version = "0.8.1", features = ["serialize"] }
once_cell = { version = "1.13" } once_cell = { version = "1.13" }
olpc-cjson = { version = "0.1.1" } olpc-cjson = { version = "0.1.1" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@ -28,7 +29,6 @@ serde_json = { version = "1", features = ["preserve_order"] }
siphasher = { version = "0.3.10" } siphasher = { version = "0.3.10" }
radicle-git-ext = { version = "0", features = ["serde"] } radicle-git-ext = { version = "0", features = ["serde"] }
sqlite = { version = "0.30.3", optional = true } sqlite = { version = "0.30.3", optional = true }
nonempty = { version = "0.8.0", features = ["serialize"] }
tempfile = { version = "3.3.0" } tempfile = { version = "3.3.0" }
thiserror = { version = "1" } thiserror = { version = "1" }
zeroize = { version = "1.5.7" } zeroize = { version = "1.5.7" }

View File

@ -15,6 +15,8 @@ use crate::cob::{store, ObjectId, OpId, TypeName};
use crate::crypto::{PublicKey, Signer}; use crate::crypto::{PublicKey, Signer};
use crate::storage::git as storage; use crate::storage::git as storage;
use super::op::Ops;
/// Issue operation. /// Issue operation.
pub type Op = crate::cob::Op<Action>; pub type Op = crate::cob::Op<Action>;
@ -110,8 +112,8 @@ impl store::FromHistory for Issue {
history: &radicle_cob::History, history: &radicle_cob::History,
) -> Result<(Self, clock::Lamport), store::Error> { ) -> Result<(Self, clock::Lamport), store::Error> {
let obj = history.traverse(Self::default(), |mut acc, entry| { let obj = history.traverse(Self::default(), |mut acc, entry| {
if let Ok(op) = Op::try_from(entry) { if let Ok(Ops(changes)) = Ops::try_from(entry) {
if let Err(err) = acc.apply(op) { if let Err(err) = acc.apply(changes) {
log::warn!("Error applying op to issue state: {err}"); log::warn!("Error applying op to issue state: {err}");
return ControlFlow::Break(acc); return ControlFlow::Break(acc);
} }
@ -153,31 +155,33 @@ impl Issue {
self.thread.comments().map(|(id, comment)| (id, comment)) self.thread.comments().map(|(id, comment)| (id, comment))
} }
pub fn apply(&mut self, op: Op) -> Result<(), Error> { pub fn apply(&mut self, changes: impl IntoIterator<Item = Op>) -> Result<(), Error> {
match op.action { for change in changes {
match change.action {
Action::Title { title } => { Action::Title { title } => {
self.title.set(title, op.clock); self.title.set(title, change.clock);
} }
Action::Lifecycle { status } => { Action::Lifecycle { status } => {
self.status.set(status, op.clock); self.status.set(status, change.clock);
} }
Action::Tag { add, remove } => { Action::Tag { add, remove } => {
for tag in add { for tag in add {
self.tags.insert(tag, op.clock); self.tags.insert(tag, change.clock);
} }
for tag in remove { for tag in remove {
self.tags.remove(tag, op.clock); self.tags.remove(tag, change.clock);
} }
} }
Action::Thread { action } => { Action::Thread { action } => {
self.thread.apply([cob::Op { self.thread.apply([cob::Op {
action, action,
author: op.author, author: change.author,
clock: op.clock, clock: change.clock,
timestamp: op.timestamp, timestamp: change.timestamp,
}]); }]);
} }
} }
}
Ok(()) Ok(())
} }
} }
@ -291,7 +295,7 @@ impl<'a, 'g> IssueMut<'a, 'g> {
clock, clock,
timestamp, timestamp,
}; };
self.issue.apply(op)?; self.issue.apply([op])?;
Ok((clock, *signer.public_key())) Ok((clock, *signer.public_key()))
} }

View File

@ -1,5 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use nonempty::NonEmpty;
use thiserror::Error; use thiserror::Error;
use radicle_cob::history::EntryWithClock; use radicle_cob::history::EntryWithClock;
@ -35,18 +36,32 @@ pub struct Op<A> {
pub timestamp: clock::Physical, pub timestamp: clock::Physical,
} }
impl<'a: 'de, 'de, A: serde::Deserialize<'de>> TryFrom<&'a EntryWithClock> for Op<A> { pub struct Ops<A>(pub NonEmpty<Op<A>>);
impl<'a, A> TryFrom<&'a EntryWithClock> for Ops<A>
where
for<'de> A: serde::Deserialize<'de>,
{
type Error = OpDecodeError; type Error = OpDecodeError;
fn try_from(entry: &'a EntryWithClock) -> Result<Self, Self::Error> { fn try_from(entry: &'a EntryWithClock) -> Result<Self, Self::Error> {
let action = serde_json::from_slice(entry.contents())?; let mut clock = entry.clock().into();
entry
Ok(Op { .contents()
.clone()
.try_map(|op| {
let action = serde_json::from_slice(&op)?;
let op = Op {
action, action,
author: *entry.actor(), author: *entry.actor(),
clock: entry.clock().into(), clock,
timestamp: entry.timestamp().into(), timestamp: entry.timestamp().into(),
};
clock.tick();
Ok(op)
}) })
.map(Self)
} }
} }

View File

@ -25,6 +25,8 @@ use crate::storage::git as storage;
/// The logical clock we use to order operations to patches. /// The logical clock we use to order operations to patches.
pub use clock::Lamport as Clock; pub use clock::Lamport as Clock;
use super::op::Ops;
/// Type name of a patch. /// Type name of a patch.
pub static TYPENAME: Lazy<TypeName> = pub static TYPENAME: Lazy<TypeName> =
Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid")); Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
@ -327,8 +329,8 @@ impl store::FromHistory for Patch {
history: &radicle_cob::History, history: &radicle_cob::History,
) -> Result<(Self, clock::Lamport), store::Error> { ) -> Result<(Self, clock::Lamport), store::Error> {
let obj = history.traverse(Self::default(), |mut acc, entry| { let obj = history.traverse(Self::default(), |mut acc, entry| {
if let Ok(op) = Op::try_from(entry) { if let Ok(Ops(changes)) = Ops::try_from(entry) {
if let Err(err) = acc.apply([op]) { if let Err(err) = acc.apply(changes) {
log::warn!("Error applying op to patch state: {err}"); log::warn!("Error applying op to patch state: {err}");
return ControlFlow::Break(acc); return ControlFlow::Break(acc);
} }

View File

@ -2,6 +2,7 @@
#![allow(clippy::large_enum_variant)] #![allow(clippy::large_enum_variant)]
use std::marker::PhantomData; use std::marker::PhantomData;
use nonempty::NonEmpty;
use radicle_crdt::Lamport; use radicle_crdt::Lamport;
use serde::Serialize; use serde::Serialize;
@ -21,6 +22,7 @@ pub const HISTORY_TYPE: &str = "radicle";
/// A type that can be materialized from an event history. /// A type that can be materialized from an event history.
/// All collaborative objects implement this trait. /// All collaborative objects implement this trait.
pub trait FromHistory: Sized { pub trait FromHistory: Sized {
// TODO(finto): Action not being used smells fishy to me
type Action; type Action;
/// The object type name. /// The object type name.
@ -100,7 +102,7 @@ where
action: T::Action, action: T::Action,
signer: &G, signer: &G,
) -> Result<CollaborativeObject, Error> { ) -> Result<CollaborativeObject, Error> {
let changes = encoding::encode(&action)?; let changes = NonEmpty::new(encoding::encode(&action)?);
cob::update( cob::update(
self.raw, self.raw,
@ -125,7 +127,7 @@ where
action: T::Action, action: T::Action,
signer: &G, signer: &G,
) -> Result<(ObjectId, T, Lamport), Error> { ) -> Result<(ObjectId, T, Lamport), Error> {
let contents = encoding::encode(&action)?; let contents = NonEmpty::new(encoding::encode(&action)?);
let cob = cob::create( let cob = cob::create(
self.raw, self.raw,
signer, signer,

View File

@ -19,6 +19,8 @@ use crdt::lwwset::LWWSet;
use crdt::redactable::Redactable; use crdt::redactable::Redactable;
use crdt::Semilattice; use crdt::Semilattice;
use super::op::Ops;
/// Type name of a thread. /// Type name of a thread.
pub static TYPENAME: Lazy<TypeName> = pub static TYPENAME: Lazy<TypeName> =
Lazy::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid")); Lazy::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid"));
@ -97,8 +99,8 @@ impl store::FromHistory for Thread {
fn from_history(history: &History) -> Result<(Self, Lamport), store::Error> { fn from_history(history: &History) -> Result<(Self, Lamport), store::Error> {
let obj = history.traverse(Thread::default(), |mut acc, entry| { let obj = history.traverse(Thread::default(), |mut acc, entry| {
if let Ok(change) = Op::try_from(entry) { if let Ok(Ops(changes)) = Ops::try_from(entry) {
acc.apply([change]); acc.apply(changes);
ControlFlow::Continue(acc) ControlFlow::Continue(acc)
} else { } else {
ControlFlow::Break(acc) ControlFlow::Break(acc)