add a transform function for operations

This commit is contained in:
Dustin J. Mitchell 2019-12-28 12:17:42 -05:00
parent 72a12c751e
commit 8799636c1a
3 changed files with 137 additions and 18 deletions

View file

@ -2,4 +2,5 @@
#![allow(dead_code)] #![allow(dead_code)]
mod errors; mod errors;
mod operation;
mod taskdb; mod taskdb;

129
src/operation.rs Normal file
View file

@ -0,0 +1,129 @@
use chrono::{DateTime, Utc};
use serde_json::Value;
use uuid::Uuid;
#[derive(PartialEq, Clone, Debug)]
pub enum Operation {
Create {
uuid: Uuid,
},
Update {
uuid: Uuid,
property: String,
value: Value,
timestamp: DateTime<Utc>,
},
}
use Operation::*;
impl Operation {
// Transform takes two operations A and B that happened concurrently and produces two
// operations A' and B' such that `apply(apply(S, A), B') = apply(apply(S, B), A')`. This
// function is used to serialize operations in a process similar to a Git "rebase".
//
// *
// / \
// op1 / \ op2
// / \
// * *
//
// this function "completes the diamond:
//
// * *
// \ /
// op2' \ / op1'
// \ /
// *
//
// such that applying op2' after op1 has the same effect as applying op1' after op2. This
// allows two different systems which have already applied op1 and op2, respectively, and thus
// reached different states, to return to the same state by applying op2' and op1',
// respectively.
pub fn transform(
operation1: Operation,
operation2: Operation,
) -> (Option<Operation>, Option<Operation>) {
match (&operation1, &operation2) {
// Two creations of the same uuid reach the same state, so there's no need for any
// further operations to bring the state together.
(&Create { uuid: uuid1 }, &Create { uuid: uuid2 }) if uuid1 == uuid2 => (None, None),
// Two updates to the same property of the same task might conflict.
(
&Update {
uuid: ref uuid1,
property: ref property1,
value: ref value1,
timestamp: ref timestamp1,
},
&Update {
uuid: ref uuid2,
property: ref property2,
value: ref value2,
timestamp: ref timestamp2,
},
) if uuid1 == uuid2 && property1 == property2 => {
// if the value is the same, there's no conflict
if value1 == value2 {
(None, None)
} else if timestamp1 < timestamp2 {
// prefer the later modification
(None, Some(operation2))
} else if timestamp1 > timestamp2 {
// prefer the later modification
(Some(operation1), None)
} else {
// arbitrarily resolve in favor of the first operation
(Some(operation1), None)
}
}
// anything else is not a conflict of any sort, so return the operations unchanged
(_, _) => (Some(operation1), Some(operation2)),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::taskdb::DB;
fn test_transform(
o1: Operation,
o2: Operation,
exp1p: Option<Operation>,
exp2p: Option<Operation>,
) {
let (o1p, o2p) = Operation::transform(o1.clone(), o2.clone());
assert_eq!((&o1p, &o2p), (&exp1p, &exp2p));
// check that the two operation sequences have the same effect, enforcing the invariant of
// the transform function.
let mut db1 = DB::new();
db1.apply(o1).unwrap();
if let Some(o) = o2p {
db1.apply(o).unwrap();
}
let mut db2 = DB::new();
db2.apply(o2).unwrap();
if let Some(o) = o1p {
db2.apply(o).unwrap();
}
assert_eq!(db1.tasks(), db2.tasks());
}
#[test]
fn test_unrelated_create() {
let uuid1 = Uuid::new_v4();
let uuid2 = Uuid::new_v4();
test_transform(
Operation::Create { uuid: uuid1 },
Operation::Create { uuid: uuid2 },
Some(Operation::Create { uuid: uuid1 }),
Some(Operation::Create { uuid: uuid2 }),
);
}
}

View file

@ -1,24 +1,12 @@
use crate::errors::Error; use crate::errors::Error;
use chrono::{DateTime, Utc}; use crate::operation::Operation;
use serde_json::Value; use serde_json::Value;
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
#[derive(PartialEq, Clone, Debug)] #[derive(PartialEq, Debug)]
enum Operation { pub struct DB {
Create {
uuid: Uuid,
},
Update {
uuid: Uuid,
property: String,
value: Value,
timestamp: DateTime<Utc>,
},
}
struct DB {
// The current state, with all pending operations applied // The current state, with all pending operations applied
tasks: HashMap<Uuid, HashMap<String, Value>>, tasks: HashMap<Uuid, HashMap<String, Value>>,
@ -33,7 +21,7 @@ struct DB {
impl DB { impl DB {
/// Create a new, empty database /// Create a new, empty database
fn new() -> DB { pub fn new() -> DB {
DB { DB {
tasks: HashMap::new(), tasks: HashMap::new(),
base_version: 0, base_version: 0,
@ -43,7 +31,7 @@ impl DB {
/// Apply an operation to the DB. Aside from synchronization operations, this /// Apply an operation to the DB. Aside from synchronization operations, this
/// is the only way to modify the DB. /// is the only way to modify the DB.
fn apply(&mut self, op: Operation) -> Result<(), Error> { pub fn apply(&mut self, op: Operation) -> Result<(), Error> {
match op { match op {
Operation::Create { uuid } => { Operation::Create { uuid } => {
match self.tasks.entry(uuid) { match self.tasks.entry(uuid) {
@ -78,7 +66,7 @@ impl DB {
/// Get a read-only reference to the underlying set of tasks. /// Get a read-only reference to the underlying set of tasks.
/// ///
/// This API is temporary, but provides query access to the DB. /// This API is temporary, but provides query access to the DB.
fn tasks(&self) -> &HashMap<Uuid, HashMap<String, Value>> { pub fn tasks(&self) -> &HashMap<Uuid, HashMap<String, Value>> {
&self.tasks &self.tasks
} }
} }
@ -86,6 +74,7 @@ impl DB {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
#[test] #[test]