From 8799636c1a4a5b0ae1acd55209433d3e75e37ee8 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sat, 28 Dec 2019 12:17:42 -0500 Subject: [PATCH] add a transform function for operations --- src/lib.rs | 1 + src/operation.rs | 129 +++++++++++++++++++++++++++++++++++++++++++++++ src/taskdb.rs | 25 +++------ 3 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 src/operation.rs diff --git a/src/lib.rs b/src/lib.rs index dd2163137..3ef1ecf6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,5 @@ #![allow(dead_code)] mod errors; +mod operation; mod taskdb; diff --git a/src/operation.rs b/src/operation.rs new file mode 100644 index 000000000..3d8258b8d --- /dev/null +++ b/src/operation.rs @@ -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, + }, +} + +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, Option) { + 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, + exp2p: Option, + ) { + 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 }), + ); + } +} diff --git a/src/taskdb.rs b/src/taskdb.rs index e5d8ee195..acc555c0b 100644 --- a/src/taskdb.rs +++ b/src/taskdb.rs @@ -1,24 +1,12 @@ use crate::errors::Error; -use chrono::{DateTime, Utc}; +use crate::operation::Operation; use serde_json::Value; use std::collections::hash_map::Entry; use std::collections::HashMap; use uuid::Uuid; -#[derive(PartialEq, Clone, Debug)] -enum Operation { - Create { - uuid: Uuid, - }, - Update { - uuid: Uuid, - property: String, - value: Value, - timestamp: DateTime, - }, -} - -struct DB { +#[derive(PartialEq, Debug)] +pub struct DB { // The current state, with all pending operations applied tasks: HashMap>, @@ -33,7 +21,7 @@ struct DB { impl DB { /// Create a new, empty database - fn new() -> DB { + pub fn new() -> DB { DB { tasks: HashMap::new(), base_version: 0, @@ -43,7 +31,7 @@ impl DB { /// Apply an operation to the DB. Aside from synchronization operations, this /// 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 { Operation::Create { uuid } => { match self.tasks.entry(uuid) { @@ -78,7 +66,7 @@ impl DB { /// Get a read-only reference to the underlying set of tasks. /// /// This API is temporary, but provides query access to the DB. - fn tasks(&self) -> &HashMap> { + pub fn tasks(&self) -> &HashMap> { &self.tasks } } @@ -86,6 +74,7 @@ impl DB { #[cfg(test)] mod tests { use super::*; + use chrono::Utc; use uuid::Uuid; #[test]