Add support for Delete operations

This commit is contained in:
Dustin J. Mitchell
2019-12-29 13:16:42 -05:00
parent e83bdc28cd
commit 41acb1fa1e
5 changed files with 175 additions and 51 deletions

View File

@@ -25,12 +25,16 @@ See below for details on how tasks can "expire" from the task database.
Every change to the task database is captured as an operation. Every change to the task database is captured as an operation.
Each operation has one of the forms Each operation has one of the forms
* `Create(uuid)` * `Create(uuid)`
* `Delete(uuid)`
* `Update(uuid, property, value, timestamp)` * `Update(uuid, property, value, timestamp)`
The former form creates a new task. The Create form creates a new task.
It is invalid to create a task that already exists. It is invalid to create a task that already exists.
The latter form updates the given property of the given task, where property and value are both strings. Similarly, the Delete form deletes an existing task.
It is invalid to delete a task that does not exist.
The Update form updates the given property of the given task, where property and value are both strings.
Value can also be `None` to indicate deletion of a property. Value can also be `None` to indicate deletion of a property.
It is invalid to update a task that does not exist. It is invalid to update a task that does not exist.
The timestamp on updates serves as additional metadata and is used to resolve conflicts. The timestamp on updates serves as additional metadata and is used to resolve conflicts.

View File

@@ -5,11 +5,16 @@ use uuid::Uuid;
/// An Operation defines a single change to the task database /// An Operation defines a single change to the task database
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] #[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub enum Operation { pub enum Operation {
/// Create a new task; if the task already exists in the DB. /// Create a new task.
/// ///
/// On application, if the task already exists, the operation does nothing. /// On application, if the task already exists, the operation does nothing.
Create { uuid: Uuid }, 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 /// Update an existing task, setting the given property to the given value. If the value is
/// None, then the corresponding property is deleted. /// None, then the corresponding property is deleted.
/// ///
@@ -52,9 +57,36 @@ impl Operation {
operation2: Operation, operation2: Operation,
) -> (Option<Operation>, Option<Operation>) { ) -> (Option<Operation>, Option<Operation>) {
match (&operation1, &operation2) { match (&operation1, &operation2) {
// Two creations of the same uuid reach the same state, so there's no need for any // Two creations or deletions of the same uuid reach the same state, so there's no need
// further operations to bring the state together. // for any further operations to bring the state together.
(&Create { uuid: uuid1 }, &Create { uuid: uuid2 }) if uuid1 == uuid2 => (None, None), (&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. // Two updates to the same property of the same task might conflict.
( (
@@ -103,6 +135,7 @@ mod test {
// thoroughly, so this testing is light. // thoroughly, so this testing is light.
fn test_transform( fn test_transform(
setup: Option<Operation>,
o1: Operation, o1: Operation,
o2: Operation, o2: Operation,
exp1p: Option<Operation>, exp1p: Option<Operation>,
@@ -114,15 +147,23 @@ mod test {
// check that the two operation sequences have the same effect, enforcing the invariant of // check that the two operation sequences have the same effect, enforcing the invariant of
// the transform function. // the transform function.
let mut db1 = DB::new(); let mut db1 = DB::new();
db1.apply(o1); if let Some(ref o) = setup {
db1.apply(o.clone()).unwrap();
}
db1.apply(o1).unwrap();
if let Some(o) = o2p { if let Some(o) = o2p {
db1.apply(o); db1.apply(o).unwrap();
} }
let mut db2 = DB::new(); let mut db2 = DB::new();
db2.apply(o2); if let Some(ref o) = setup {
if let Some(o) = o1p { db2.apply(o.clone()).unwrap();
db2.apply(o);
} }
db2.apply(o2).unwrap();
if let Some(o) = o1p {
db2.apply(o).unwrap();
}
assert_eq!(db1.tasks(), db2.tasks()); assert_eq!(db1.tasks(), db2.tasks());
} }
@@ -132,6 +173,7 @@ mod test {
let uuid2 = Uuid::new_v4(); let uuid2 = Uuid::new_v4();
test_transform( test_transform(
None,
Create { uuid: uuid1 }, Create { uuid: uuid1 },
Create { uuid: uuid2 }, Create { uuid: uuid2 },
Some(Create { uuid: uuid1 }), Some(Create { uuid: uuid1 }),
@@ -145,6 +187,7 @@ mod test {
let timestamp = Utc::now(); let timestamp = Utc::now();
test_transform( test_transform(
Some(Create { uuid }),
Update { Update {
uuid, uuid,
property: "abc".into(), property: "abc".into(),
@@ -179,6 +222,7 @@ mod test {
let timestamp2 = timestamp1 + Duration::seconds(10); let timestamp2 = timestamp1 + Duration::seconds(10);
test_transform( test_transform(
Some(Create { uuid }),
Update { Update {
uuid, uuid,
property: "abc".into(), property: "abc".into(),
@@ -207,6 +251,7 @@ mod test {
let timestamp = Utc::now(); let timestamp = Utc::now();
test_transform( test_transform(
Some(Create { uuid }),
Update { Update {
uuid, uuid,
property: "abc".into(), property: "abc".into(),

View File

@@ -1,3 +1,4 @@
use crate::errors::Error;
use crate::operation::Operation; use crate::operation::Operation;
use crate::server::{Server, VersionAdd}; use crate::server::{Server, VersionAdd};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -41,15 +42,30 @@ 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. In cases where an operation does not /// is the only way to modify the DB. In cases where an operation does not
/// make sense, this function will ignore the operation. /// make sense, this function will ignore the operation.
pub fn apply(&mut self, op: Operation) { pub fn apply(&mut self, op: Operation) -> Result<(), Error> {
if let err @ Err(_) = self.apply_op(&op) {
return err;
}
self.operations.push(op);
Ok(())
}
fn apply_op(&mut self, op: &Operation) -> Result<(), Error> {
match op { match op {
Operation::Create { uuid } => { &Operation::Create { uuid } => {
// insert if the task does not already exist // insert if the task does not already exist
if let ent @ Entry::Vacant(_) = self.tasks.entry(uuid) { if let ent @ Entry::Vacant(_) = self.tasks.entry(uuid) {
ent.or_insert(HashMap::new()); ent.or_insert(HashMap::new());
} else {
return Err(Error::DBError(format!("Task {} already exists", uuid)));
} }
} }
Operation::Update { &Operation::Delete { ref uuid } => {
if let None = self.tasks.remove(uuid) {
return Err(Error::DBError(format!("Task {} does not exist", uuid)));
}
}
&Operation::Update {
ref uuid, ref uuid,
ref property, ref property,
ref value, ref value,
@@ -57,18 +73,17 @@ impl DB {
} => { } => {
// update if this task exists, otherwise ignore // update if this task exists, otherwise ignore
if let Some(task) = self.tasks.get_mut(uuid) { if let Some(task) = self.tasks.get_mut(uuid) {
DB::apply_update(task, property, value); match value {
Some(ref val) => task.insert(property.to_string(), val.clone()),
None => task.remove(property),
};
} else {
return Err(Error::DBError(format!("Task {} does not exist", uuid)));
} }
} }
}; }
self.operations.push(op);
}
fn apply_update(task: &mut TaskMap, property: &str, value: &Option<String>) { Ok(())
match value {
Some(ref val) => task.insert(property.to_string(), val.clone()),
None => task.remove(property),
};
} }
/// Get a read-only reference to the underlying set of tasks. /// Get a read-only reference to the underlying set of tasks.
@@ -152,7 +167,9 @@ impl DB {
} }
} }
if let Some(o) = svr_op { if let Some(o) = svr_op {
self.apply(o); if let Err(e) = self.apply_op(&o) {
println!("Invalid operation when syncing: {} (ignored)", e);
}
} }
self.operations = new_local_ops; self.operations = new_local_ops;
} }
@@ -171,7 +188,7 @@ mod tests {
let mut db = DB::new(); let mut db = DB::new();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let op = Operation::Create { uuid }; let op = Operation::Create { uuid };
db.apply(op.clone()); db.apply(op.clone()).unwrap();
let mut exp = HashMap::new(); let mut exp = HashMap::new();
exp.insert(uuid, HashMap::new()); exp.insert(uuid, HashMap::new());
@@ -184,13 +201,16 @@ mod tests {
let mut db = DB::new(); let mut db = DB::new();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let op = Operation::Create { uuid }; let op = Operation::Create { uuid };
db.apply(op.clone()); db.apply(op.clone()).unwrap();
db.apply(op.clone()); assert_eq!(
db.apply(op.clone()).err().unwrap(),
Error::DBError(format!("Task {} already exists", uuid))
);
let mut exp = HashMap::new(); let mut exp = HashMap::new();
exp.insert(uuid, HashMap::new()); exp.insert(uuid, HashMap::new());
assert_eq!(db.tasks(), &exp); assert_eq!(db.tasks(), &exp);
assert_eq!(db.operations, vec![op.clone(), op]); assert_eq!(db.operations, vec![op]);
} }
#[test] #[test]
@@ -198,14 +218,14 @@ mod tests {
let mut db = DB::new(); let mut db = DB::new();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let op1 = Operation::Create { uuid }; let op1 = Operation::Create { uuid };
db.apply(op1.clone()); db.apply(op1.clone()).unwrap();
let op2 = Operation::Update { let op2 = Operation::Update {
uuid, uuid,
property: String::from("title"), property: String::from("title"),
value: Some("my task".into()), value: Some("my task".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
db.apply(op2.clone()); db.apply(op2.clone()).unwrap();
let mut exp = HashMap::new(); let mut exp = HashMap::new();
let mut task = HashMap::new(); let mut task = HashMap::new();
@@ -220,7 +240,7 @@ mod tests {
let mut db = DB::new(); let mut db = DB::new();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let op1 = Operation::Create { uuid }; let op1 = Operation::Create { uuid };
db.apply(op1.clone()); db.apply(op1.clone()).unwrap();
let op2 = Operation::Update { let op2 = Operation::Update {
uuid, uuid,
@@ -228,7 +248,7 @@ mod tests {
value: Some("my task".into()), value: Some("my task".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
db.apply(op2.clone()); db.apply(op2.clone()).unwrap();
let op3 = Operation::Update { let op3 = Operation::Update {
uuid, uuid,
@@ -236,7 +256,7 @@ mod tests {
value: Some("H".into()), value: Some("H".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
db.apply(op3.clone()); db.apply(op3.clone()).unwrap();
let op4 = Operation::Update { let op4 = Operation::Update {
uuid, uuid,
@@ -244,7 +264,7 @@ mod tests {
value: None, value: None,
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
db.apply(op4.clone()); db.apply(op4.clone()).unwrap();
let mut exp = HashMap::new(); let mut exp = HashMap::new();
let mut task = HashMap::new(); let mut task = HashMap::new();
@@ -264,9 +284,41 @@ mod tests {
value: Some("my task".into()), value: Some("my task".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
db.apply(op.clone()); assert_eq!(
db.apply(op).err().unwrap(),
Error::DBError(format!("Task {} does not exist", uuid))
);
assert_eq!(db.tasks(), &HashMap::new()); assert_eq!(db.tasks(), &HashMap::new());
assert_eq!(db.operations, vec![op]); assert_eq!(db.operations, vec![]);
}
#[test]
fn test_apply_create_delete() {
let mut db = DB::new();
let uuid = Uuid::new_v4();
let op1 = Operation::Create { uuid };
db.apply(op1.clone()).unwrap();
let op2 = Operation::Delete { uuid };
db.apply(op2.clone()).unwrap();
assert_eq!(db.tasks(), &HashMap::new());
assert_eq!(db.operations, vec![op1, op2]);
}
#[test]
fn test_apply_delete_not_present() {
let mut db = DB::new();
let uuid = Uuid::new_v4();
let op1 = Operation::Delete { uuid };
assert_eq!(
db.apply(op1).err().unwrap(),
Error::DBError(format!("Task {} does not exist", uuid))
);
assert_eq!(db.tasks(), &HashMap::new());
assert_eq!(db.operations, vec![]);
} }
} }

View File

@@ -16,6 +16,7 @@ fn uuid_strategy() -> impl Strategy<Value = Uuid> {
fn operation_strategy() -> impl Strategy<Value = Operation> { fn operation_strategy() -> impl Strategy<Value = Operation> {
prop_oneof![ prop_oneof![
uuid_strategy().prop_map(|uuid| Operation::Create { uuid }), uuid_strategy().prop_map(|uuid| Operation::Create { uuid }),
uuid_strategy().prop_map(|uuid| Operation::Delete { uuid }),
(uuid_strategy(), "(title|project|status)").prop_map(|(uuid, property)| { (uuid_strategy(), "(title|project|status)").prop_map(|(uuid, property)| {
Operation::Update { Operation::Update {
uuid, uuid,
@@ -28,32 +29,50 @@ fn operation_strategy() -> impl Strategy<Value = Operation> {
} }
proptest! { proptest! {
#![proptest_config(ProptestConfig {
cases: 1024, .. ProptestConfig::default()
})]
#[test] #[test]
// check that the two operation sequences have the same effect, enforcing the invariant of
// the transform function.
fn transform_invariant_holds(o1 in operation_strategy(), o2 in operation_strategy()) { fn transform_invariant_holds(o1 in operation_strategy(), o2 in operation_strategy()) {
let (o1p, o2p) = Operation::transform(o1.clone(), o2.clone()); let (o1p, o2p) = Operation::transform(o1.clone(), o2.clone());
// check that the two operation sequences have the same effect, enforcing the invariant of
// the transform function. This needs some care as if either of the operations is
// an Update then we must ensure the task already exists in the DB.
let mut db1 = DB::new(); let mut db1 = DB::new();
// Ensure that any expected tasks already exist
if let Operation::Update{ ref uuid, .. } = o1 { if let Operation::Update{ ref uuid, .. } = o1 {
db1.apply(Operation::Create{uuid: uuid.clone()}); let _ = db1.apply(Operation::Create{uuid: uuid.clone()});
} }
if let Operation::Update{ ref uuid, .. } = o2 { if let Operation::Update{ ref uuid, .. } = o2 {
db1.apply(Operation::Create{uuid: uuid.clone()}); let _ = db1.apply(Operation::Create{uuid: uuid.clone()});
}
if let Operation::Delete{ ref uuid } = o1 {
let _ = db1.apply(Operation::Create{uuid: uuid.clone()});
}
if let Operation::Delete{ ref uuid } = o2 {
let _ = db1.apply(Operation::Create{uuid: uuid.clone()});
} }
let mut db2 = db1.clone(); let mut db2 = db1.clone();
db1.apply(o1); // if applying the initial operations fail, that indicates the operation was invalid
if let Some(o) = o2p { // in the base state, so consider the case successful.
db1.apply(o); if let Err(_) = db1.apply(o1) {
return Ok(());
}
if let Err(_) = db2.apply(o2) {
return Ok(());
}
if let Some(o) = o2p {
db1.apply(o).map_err(|e| TestCaseError::Fail(format!("Applying to db1: {}", e).into()))?;
} }
db2.apply(o2);
if let Some(o) = o1p { if let Some(o) = o1p {
db2.apply(o); db2.apply(o).map_err(|e| TestCaseError::Fail(format!("Applying to db2: {}", e).into()))?;
} }
assert_eq!(db1.tasks(), db2.tasks()); assert_eq!(db1.tasks(), db2.tasks());
} }

View File

@@ -14,22 +14,24 @@ fn test_sync() {
// make some changes in parallel to db1 and db2.. // make some changes in parallel to db1 and db2..
let uuid1 = Uuid::new_v4(); let uuid1 = Uuid::new_v4();
db1.apply(Operation::Create { uuid: uuid1 }); db1.apply(Operation::Create { uuid: uuid1 }).unwrap();
db1.apply(Operation::Update { db1.apply(Operation::Update {
uuid: uuid1, uuid: uuid1,
property: "title".into(), property: "title".into(),
value: Some("my first task".into()), value: Some("my first task".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}); })
.unwrap();
let uuid2 = Uuid::new_v4(); let uuid2 = Uuid::new_v4();
db2.apply(Operation::Create { uuid: uuid2 }); db2.apply(Operation::Create { uuid: uuid2 }).unwrap();
db2.apply(Operation::Update { db2.apply(Operation::Update {
uuid: uuid2, uuid: uuid2,
property: "title".into(), property: "title".into(),
value: Some("my second task".into()), value: Some("my second task".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}); })
.unwrap();
// and synchronize those around // and synchronize those around
db1.sync("me", &mut server); db1.sync("me", &mut server);
@@ -43,13 +45,15 @@ fn test_sync() {
property: "priority".into(), property: "priority".into(),
value: Some("H".into()), value: Some("H".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}); })
.unwrap();
db2.apply(Operation::Update { db2.apply(Operation::Update {
uuid: uuid2, uuid: uuid2,
property: "project".into(), property: "project".into(),
value: Some("personal".into()), value: Some("personal".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}); })
.unwrap();
// and synchronize those around // and synchronize those around
db1.sync("me", &mut server); db1.sync("me", &mut server);