use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// An Operation defines a single change to the task database #[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] pub enum Operation { /// Create a new task. /// /// On application, if the task already exists, the operation does nothing. Create { uuid: Uuid }, /// Delete an existing task. /// /// On application, if the task does not exist, the operation does nothing. Delete { uuid: Uuid }, /// Update an existing task, setting the given property to the given value. If the value is /// None, then the corresponding property is deleted. /// /// If the given task does not exist, the operation does nothing. Update { uuid: Uuid, property: String, value: Option, 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 or deletions 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), (&Delete { uuid: uuid1 }, &Delete { uuid: uuid2 }) if uuid1 == uuid2 => (None, None), // Given a create and a delete of the same task, one of the operations is invalid: the // create implies the task does not exist, but the delete implies it exists. Somewhat // arbitrarily, we prefer the Create (&Create { uuid: uuid1 }, &Delete { uuid: uuid2 }) if uuid1 == uuid2 => { (Some(operation1), None) } (&Delete { uuid: uuid1 }, &Create { uuid: uuid2 }) if uuid1 == uuid2 => { (None, Some(operation2)) } // And again from an Update and a Create, prefer the Update (&Update { uuid: uuid1, .. }, &Create { uuid: uuid2 }) if uuid1 == uuid2 => { (Some(operation1), None) } (&Create { uuid: uuid1 }, &Update { uuid: uuid2, .. }) if uuid1 == uuid2 => { (None, Some(operation2)) } // Given a delete and an update, prefer the delete (&Update { uuid: uuid1, .. }, &Delete { uuid: uuid2 }) if uuid1 == uuid2 => { (None, Some(operation2)) } (&Delete { uuid: uuid1 }, &Update { uuid: uuid2, .. }) if uuid1 == uuid2 => { (Some(operation1), 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) (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; use chrono::{Duration, Utc}; // note that `tests/operation_transform_invariant.rs` tests the transform function quite // thoroughly, so this testing is light. fn test_transform( setup: Option, 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_inmemory(); if let Some(ref o) = setup { db1.apply(o.clone()).unwrap(); } db1.apply(o1).unwrap(); if let Some(o) = o2p { db1.apply(o).unwrap(); } let mut db2 = DB::new_inmemory(); if let Some(ref o) = setup { db2.apply(o.clone()).unwrap(); } db2.apply(o2).unwrap(); if let Some(o) = o1p { db2.apply(o).unwrap(); } assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); } #[test] fn test_unrelated_create() { let uuid1 = Uuid::new_v4(); let uuid2 = Uuid::new_v4(); test_transform( None, Create { uuid: uuid1 }, Create { uuid: uuid2 }, Some(Create { uuid: uuid1 }), Some(Create { uuid: uuid2 }), ); } #[test] fn test_related_updates_different_props() { let uuid = Uuid::new_v4(); let timestamp = Utc::now(); test_transform( Some(Create { uuid }), Update { uuid, property: "abc".into(), value: Some("true".into()), timestamp, }, Update { uuid, property: "def".into(), value: Some("false".into()), timestamp, }, Some(Update { uuid, property: "abc".into(), value: Some("true".into()), timestamp, }), Some(Update { uuid, property: "def".into(), value: Some("false".into()), timestamp, }), ); } #[test] fn test_related_updates_same_prop() { let uuid = Uuid::new_v4(); let timestamp1 = Utc::now(); let timestamp2 = timestamp1 + Duration::seconds(10); test_transform( Some(Create { uuid }), Update { uuid, property: "abc".into(), value: Some("true".into()), timestamp: timestamp1, }, Update { uuid, property: "abc".into(), value: Some("false".into()), timestamp: timestamp2, }, None, Some(Update { uuid, property: "abc".into(), value: Some("false".into()), timestamp: timestamp2, }), ); } #[test] fn test_related_updates_same_prop_same_time() { let uuid = Uuid::new_v4(); let timestamp = Utc::now(); test_transform( Some(Create { uuid }), Update { uuid, property: "abc".into(), value: Some("true".into()), timestamp, }, Update { uuid, property: "abc".into(), value: Some("false".into()), timestamp, }, Some(Update { uuid, property: "abc".into(), value: Some("true".into()), timestamp, }), None, ); } }