Merge pull request #323 from taskchampion/issue92

Support 'undo'
This commit is contained in:
Dustin J. Mitchell 2021-12-23 09:08:01 -05:00 committed by GitHub
commit cb1395ea32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1312 additions and 469 deletions

View file

@ -59,6 +59,7 @@ pub(crate) enum Subcommand {
/// Basic operations without args /// Basic operations without args
Gc, Gc,
Sync, Sync,
Undo,
} }
impl Subcommand { impl Subcommand {
@ -72,6 +73,7 @@ impl Subcommand {
Info::parse, Info::parse,
Gc::parse, Gc::parse,
Sync::parse, Sync::parse,
Undo::parse,
// This must come last since it accepts arbitrary report names // This must come last since it accepts arbitrary report names
Report::parse, Report::parse,
)))(input) )))(input)
@ -422,6 +424,29 @@ impl Sync {
} }
} }
struct Undo;
impl Undo {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::Undo)
}
map_res(arg_matching(literal("undo")), to_subcommand)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "undo",
syntax: "undo",
summary: "Undo the latest change made on this replica",
description: "
Undo the latest change made on this replica.
Changes cannot be undone once they have been synchronized.",
})
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -814,4 +839,13 @@ mod test {
(&EMPTY[..], subcommand) (&EMPTY[..], subcommand)
); );
} }
#[test]
fn test_undo() {
let subcommand = Subcommand::Undo;
assert_eq!(
Subcommand::parse(argv!["undo"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
} }

View file

@ -8,4 +8,5 @@ pub(crate) mod info;
pub(crate) mod modify; pub(crate) mod modify;
pub(crate) mod report; pub(crate) mod report;
pub(crate) mod sync; pub(crate) mod sync;
pub(crate) mod undo;
pub(crate) mod version; pub(crate) mod version;

View file

@ -0,0 +1,28 @@
use taskchampion::Replica;
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
if replica.undo()? {
writeln!(w, "Undo successful.")?;
} else {
writeln!(w, "Nothing to undo.")?;
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
#[test]
fn test_undo() {
let mut w = test_writer();
let mut replica = test_replica();
// Note that the details of the actual undo operation are tested thoroughly in the taskchampion crate
execute(&mut w, &mut replica).unwrap();
assert_eq!(&w.into_string(), "Nothing to undo.\n")
}
}

View file

@ -90,6 +90,13 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::
return cmd::sync::execute(&mut w, &mut replica, &settings, &mut server); return cmd::sync::execute(&mut w, &mut replica, &settings, &mut server);
} }
Command {
subcommand: Subcommand::Undo,
..
} => {
return cmd::undo::execute(&mut w, &mut replica);
}
// handled in the first match, but here to ensure this match is exhaustive // handled in the first match, but here to ensure this match is exhaustive
Command { Command {
subcommand: Subcommand::Help { .. }, subcommand: Subcommand::Help { .. },

View file

@ -10,6 +10,7 @@
* [Dates and Durations](./time.md) * [Dates and Durations](./time.md)
* [Configuration](./config-file.md) * [Configuration](./config-file.md)
* [Environment](./environment.md) * [Environment](./environment.md)
* [Undo](./undo.md)
* [Synchronization](./task-sync.md) * [Synchronization](./task-sync.md)
* [Running the Sync Server](./running-sync-server.md) * [Running the Sync Server](./running-sync-server.md)
- [Internal Details](./internals.md) - [Internal Details](./internals.md)

View file

@ -21,21 +21,63 @@ See [Tasks](./tasks.md) for details on the content of that map.
Every change to the task database is captured as an operation. Every change to the task database is captured as an operation.
In other words, operations act as deltas between database states. In other words, operations act as deltas between database states.
Operations are crucial to synchronization of replicas, using a technique known as Operational Transforms. Operations are crucial to synchronization of replicas, described in [Synchronization Model](./sync-model.md).
Operations are entirely managed by the replica, and some combinations of operations are described as "invalid" here.
A replica must not create invalid operations, but should be resilient to receiving invalid operations during a synchronization operation.
Each operation has one of the forms Each operation has one of the forms
* `Create(uuid)` * `Create(uuid)`
* `Delete(uuid)` * `Delete(uuid, oldTask)`
* `Update(uuid, property, value, timestamp)` * `Update(uuid, property, oldValue, newValue, timestamp)`
* `UndoPoint()`
The Create 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.
Similarly, the Delete form deletes an existing task. Similarly, the Delete form deletes an existing task.
It is invalid to delete a task that does not exist. It is invalid to delete a task that does not exist.
The `oldTask` property contains the task data from before it was deleted.
The Update form updates the given property of the given task, where property and value are both strings. The Update form updates the given property of the given task, where the property and values are strings.
Value can also be `None` to indicate deletion of a property. The `oldValue` gives the old value of the property (or None to create a new property), while `newValue` gives the new value (or None to delete 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.
### Application
Each operation can be "applied" to a task database in a natural way:
* Applying `Create` creates a new, empty task in the task database.
* Applying `Delete` deletes a task, including all of its properties, from the task database.
* Applying `Update` modifies the properties of a task.
* Applying `UndoPoint` does nothing.
### Undo
Each operation also contains enough information to reverse its application:
* Undoing `Create` deletes a task.
* Undoing `Delete` creates a task, including all of the properties in `oldTask`.
* Undoing `Update` modifies the properties of a task, reverting to `oldValue`.
* Undoing `UndoPoint` does nothing.
The `UndoPoint` operation serves as a marker of points in the operation sequence to which the user might wish to undo.
For example, creation of a new task with several properities involves several operations, but is a single step from the user's perspective.
An "undo" command reverses operations, removing them from the operations sequence, until it reaches an `UndoPoint` operation.
### Synchronizing Operations
After operations are synchronized to the server, they can no longer be undone.
As such, the [synchronization model](./sync-model.md) uses simpler operations.
Replica operations are converted to sync operations as follows:
* `Create(uuid)` -> `Create(uuid)` (no change)
* `Delete(uuid, oldTask)` -> `Delete(uuid)`
* `Update(uuid, property, oldValue, newValue, timestamp)` -> `Update(uuid, property, newValue, timestamp)`
* `UndoPoint()` -> Ø (dropped from operation sequence)
Once a sequence of operations has been synchronized, there is no need to store those operations on the replica.
The current implementation deletes operations at that time.
An alternative approach is to keep operations for existing tasks, and provide access to those operations as a "history" of modifications to the task.

View file

@ -34,6 +34,16 @@ Since the replicas are not connected, each may have additional operations that h
The synchronization process uses operational transformation to "linearize" those operations. The synchronization process uses operational transformation to "linearize" those operations.
This process is analogous (vaguely) to rebasing a sequence of Git commits. This process is analogous (vaguely) to rebasing a sequence of Git commits.
### Sync Operations
The [Replica Storage](./storage.md) model contains additional information in its operations that is not included in operations synchronized to other replicas.
In this document, we will be discussing "sync operations" of the form
* `Create(uuid)`
* `Delete(uuid)`
* `Update(uuid, property, value, timestamp)`
### Versions ### Versions
Occasionally, database states are given a name (that takes the form of a UUID). Occasionally, database states are given a name (that takes the form of a UUID).

7
docs/src/undo.md Normal file
View file

@ -0,0 +1,7 @@
# Undo
It's easy to make a mistake: mark the wrong task as done, or hit enter before noticing a typo in a tag name.
The `ta undo` command makes it just as easy to fix the mistake, by effectively reversing the most recent change.
Multiple invocations of `ta undo` can be used to undo multiple changes.
The limit of this functionality is that changes which have been synchronized to the server (via `ta sync`) cannot be undone.

View file

@ -1,6 +1,5 @@
use crate::errors::Error; use crate::server::{Server, SyncOp};
use crate::server::Server; use crate::storage::{Storage, TaskMap};
use crate::storage::{Operation, Storage, TaskMap};
use crate::task::{Status, Task}; use crate::task::{Status, Task};
use crate::taskdb::TaskDb; use crate::taskdb::TaskDb;
use crate::workingset::WorkingSet; use crate::workingset::WorkingSet;
@ -29,12 +28,14 @@ use uuid::Uuid;
/// during the garbage-collection process. /// during the garbage-collection process.
pub struct Replica { pub struct Replica {
taskdb: TaskDb, taskdb: TaskDb,
added_undo_point: bool,
} }
impl Replica { impl Replica {
pub fn new(storage: Box<dyn Storage>) -> Replica { pub fn new(storage: Box<dyn Storage>) -> Replica {
Replica { Replica {
taskdb: TaskDb::new(storage), taskdb: TaskDb::new(storage),
added_undo_point: false,
} }
} }
@ -51,12 +52,13 @@ impl Replica {
uuid: Uuid, uuid: Uuid,
property: S1, property: S1,
value: Option<S2>, value: Option<S2>,
) -> anyhow::Result<()> ) -> anyhow::Result<TaskMap>
where where
S1: Into<String>, S1: Into<String>,
S2: Into<String>, S2: Into<String>,
{ {
self.taskdb.apply(Operation::Update { self.add_undo_point(false)?;
self.taskdb.apply(SyncOp::Update {
uuid, uuid,
property: property.into(), property: property.into(),
value: value.map(|v| v.into()), value: value.map(|v| v.into()),
@ -99,10 +101,11 @@ impl Replica {
/// Create a new task. The task must not already exist. /// Create a new task. The task must not already exist.
pub fn new_task(&mut self, status: Status, description: String) -> anyhow::Result<Task> { pub fn new_task(&mut self, status: Status, description: String) -> anyhow::Result<Task> {
self.add_undo_point(false)?;
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
self.taskdb.apply(Operation::Create { uuid })?; let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?;
trace!("task {} created", uuid); trace!("task {} created", uuid);
let mut task = Task::new(uuid, TaskMap::new()).into_mut(self); let mut task = Task::new(uuid, taskmap).into_mut(self);
task.set_description(description)?; task.set_description(description)?;
task.set_status(status)?; task.set_status(status)?;
Ok(task.into_immut()) Ok(task.into_immut())
@ -113,12 +116,8 @@ impl Replica {
/// should only occur through expiration. /// should only occur through expiration.
#[allow(dead_code)] #[allow(dead_code)]
fn delete_task(&mut self, uuid: Uuid) -> anyhow::Result<()> { fn delete_task(&mut self, uuid: Uuid) -> anyhow::Result<()> {
// check that it already exists; this is a convenience check, as the task may already exist self.add_undo_point(false)?;
// when this Create operation is finally sync'd with operations from other replicas self.taskdb.apply(SyncOp::Delete { uuid })?;
if self.taskdb.get_task(uuid)?.is_none() {
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
self.taskdb.apply(Operation::Delete { uuid })?;
trace!("task {} deleted", uuid); trace!("task {} deleted", uuid);
Ok(()) Ok(())
} }
@ -146,6 +145,12 @@ impl Replica {
Ok(()) Ok(())
} }
/// Undo local operations until the most recent UndoPoint, returning false if there are no
/// local operations to undo.
pub fn undo(&mut self) -> anyhow::Result<bool> {
self.taskdb.undo()
}
/// Rebuild this replica's working set, based on whether tasks are pending or not. If /// Rebuild this replica's working set, based on whether tasks are pending or not. If
/// `renumber` is true, then existing tasks may be moved to new working-set indices; in any /// `renumber` is true, then existing tasks may be moved to new working-set indices; in any
/// case, on completion all pending tasks are in the working set and all non- pending tasks are /// case, on completion all pending tasks are in the working set and all non- pending tasks are
@ -156,11 +161,24 @@ impl Replica {
.rebuild_working_set(|t| t.get("status") == Some(&pending), renumber)?; .rebuild_working_set(|t| t.get("status") == Some(&pending), renumber)?;
Ok(()) Ok(())
} }
/// Add an UndoPoint, if one has not already been added by this Replica. This occurs
/// automatically when a change is made. The `force` flag allows forcing a new UndoPoint
/// even if one has laready been created by this Replica, and may be useful when a Replica
/// instance is held for a long time and used to apply more than one user-visible change.
pub fn add_undo_point(&mut self, force: bool) -> anyhow::Result<()> {
if force || !self.added_undo_point {
self.taskdb.add_undo_point()?;
self.added_undo_point = true;
}
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::storage::ReplicaOp;
use crate::task::Status; use crate::task::Status;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use uuid::Uuid; use uuid::Uuid;
@ -193,10 +211,95 @@ mod tests {
assert_eq!(t.get_description(), "past tense"); assert_eq!(t.get_description(), "past tense");
assert_eq!(t.get_status(), Status::Completed); assert_eq!(t.get_status(), Status::Completed);
// check tha values have changed in storage, too // check that values have changed in storage, too
let t = rep.get_task(t.get_uuid()).unwrap().unwrap(); let t = rep.get_task(t.get_uuid()).unwrap().unwrap();
assert_eq!(t.get_description(), "past tense"); assert_eq!(t.get_description(), "past tense");
assert_eq!(t.get_status(), Status::Completed); assert_eq!(t.get_status(), Status::Completed);
// and check for the corresponding operations, cleaning out the timestamps
// and modified properties as these are based on the current time
let now = Utc::now();
let clean_op = |op: ReplicaOp| {
if let ReplicaOp::Update {
uuid,
property,
mut old_value,
mut value,
..
} = op
{
if property == "modified" {
if value.is_some() {
value = Some("just-now".into());
}
if old_value.is_some() {
old_value = Some("just-now".into());
}
}
ReplicaOp::Update {
uuid,
property,
old_value,
value,
timestamp: now,
}
} else {
op
}
};
assert_eq!(
rep.taskdb
.operations()
.drain(..)
.map(clean_op)
.collect::<Vec<_>>(),
vec![
ReplicaOp::UndoPoint,
ReplicaOp::Create { uuid: t.get_uuid() },
ReplicaOp::Update {
uuid: t.get_uuid(),
property: "modified".into(),
old_value: None,
value: Some("just-now".into()),
timestamp: now,
},
ReplicaOp::Update {
uuid: t.get_uuid(),
property: "description".into(),
old_value: None,
value: Some("a task".into()),
timestamp: now,
},
ReplicaOp::Update {
uuid: t.get_uuid(),
property: "status".into(),
old_value: None,
value: Some("P".into()),
timestamp: now,
},
ReplicaOp::Update {
uuid: t.get_uuid(),
property: "modified".into(),
old_value: Some("just-now".into()),
value: Some("just-now".into()),
timestamp: now,
},
ReplicaOp::Update {
uuid: t.get_uuid(),
property: "description".into(),
old_value: Some("a task".into()),
value: Some("past tense".into()),
timestamp: now,
},
ReplicaOp::Update {
uuid: t.get_uuid(),
property: "status".into(),
old_value: Some("P".into()),
value: Some("C".into()),
timestamp: now,
},
]
);
} }
#[test] #[test]

View file

@ -14,6 +14,7 @@ pub(crate) mod test;
mod config; mod config;
mod crypto; mod crypto;
mod local; mod local;
mod op;
mod remote; mod remote;
mod types; mod types;
@ -21,3 +22,5 @@ pub use config::ServerConfig;
pub use local::LocalServer; pub use local::LocalServer;
pub use remote::RemoteServer; pub use remote::RemoteServer;
pub use types::*; pub use types::*;
pub(crate) use op::SyncOp;

View file

@ -2,9 +2,10 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
/// An Operation defines a single change to the task database /// A SyncOp defines a single change to the task database, that can be synchronized
/// via a server.
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] #[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub enum Operation { pub enum SyncOp {
/// Create a new task. /// 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.
@ -18,7 +19,7 @@ pub enum Operation {
/// 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.
/// ///
/// If the given task does not exist, the operation does nothing. /// If the given task does not exist, the operation does nothing.
Update { Update {
uuid: Uuid, uuid: Uuid,
property: String, property: String,
@ -27,9 +28,9 @@ pub enum Operation {
}, },
} }
use Operation::*; use SyncOp::*;
impl Operation { impl SyncOp {
// Transform takes two operations A and B that happened concurrently and produces two // 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 // 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". // function is used to serialize operations in a process similar to a Git "rebase".
@ -52,10 +53,7 @@ impl Operation {
// allows two different systems which have already applied op1 and op2, respectively, and thus // 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', // reached different states, to return to the same state by applying op2' and op1',
// respectively. // respectively.
pub fn transform( pub fn transform(operation1: SyncOp, operation2: SyncOp) -> (Option<SyncOp>, Option<SyncOp>) {
operation1: Operation,
operation2: Operation,
) -> (Option<Operation>, Option<Operation>) {
match (&operation1, &operation2) { match (&operation1, &operation2) {
// Two creations or deletions of the same uuid reach the same state, so there's no need // 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. // for any further operations to bring the state together.
@ -131,17 +129,86 @@ mod test {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use proptest::prelude::*; use proptest::prelude::*;
// note that `tests/operation_transform_invariant.rs` tests the transform function quite #[test]
// thoroughly, so this testing is light. fn test_json_create() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let op = Create { uuid };
let json = serde_json::to_string(&op)?;
assert_eq!(json, format!(r#"{{"Create":{{"uuid":"{}"}}}}"#, uuid));
let deser: SyncOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
#[test]
fn test_json_delete() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let op = Delete { uuid };
let json = serde_json::to_string(&op)?;
assert_eq!(json, format!(r#"{{"Delete":{{"uuid":"{}"}}}}"#, uuid));
let deser: SyncOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
#[test]
fn test_json_update() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
let op = Update {
uuid,
property: "abc".into(),
value: Some("false".into()),
timestamp,
};
let json = serde_json::to_string(&op)?;
assert_eq!(
json,
format!(
r#"{{"Update":{{"uuid":"{}","property":"abc","value":"false","timestamp":"{:?}"}}}}"#,
uuid, timestamp,
)
);
let deser: SyncOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
#[test]
fn test_json_update_none() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
let op = Update {
uuid,
property: "abc".into(),
value: None,
timestamp,
};
let json = serde_json::to_string(&op)?;
assert_eq!(
json,
format!(
r#"{{"Update":{{"uuid":"{}","property":"abc","value":null,"timestamp":"{:?}"}}}}"#,
uuid, timestamp,
)
);
let deser: SyncOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
fn test_transform( fn test_transform(
setup: Option<Operation>, setup: Option<SyncOp>,
o1: Operation, o1: SyncOp,
o2: Operation, o2: SyncOp,
exp1p: Option<Operation>, exp1p: Option<SyncOp>,
exp2p: Option<Operation>, exp2p: Option<SyncOp>,
) { ) {
let (o1p, o2p) = Operation::transform(o1.clone(), o2.clone()); let (o1p, o2p) = SyncOp::transform(o1.clone(), o2.clone());
assert_eq!((&o1p, &o2p), (&exp1p, &exp2p)); assert_eq!((&o1p, &o2p), (&exp1p, &exp2p));
// 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
@ -274,72 +341,6 @@ mod test {
); );
} }
#[test]
fn test_json_create() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let op = Create { uuid };
assert_eq!(
serde_json::to_string(&op)?,
format!(r#"{{"Create":{{"uuid":"{}"}}}}"#, uuid),
);
Ok(())
}
#[test]
fn test_json_delete() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let op = Delete { uuid };
assert_eq!(
serde_json::to_string(&op)?,
format!(r#"{{"Delete":{{"uuid":"{}"}}}}"#, uuid),
);
Ok(())
}
#[test]
fn test_json_update() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
let op = Update {
uuid,
property: "abc".into(),
value: Some("false".into()),
timestamp,
};
assert_eq!(
serde_json::to_string(&op)?,
format!(
r#"{{"Update":{{"uuid":"{}","property":"abc","value":"false","timestamp":"{:?}"}}}}"#,
uuid, timestamp,
),
);
Ok(())
}
#[test]
fn test_json_update_none() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
let op = Update {
uuid,
property: "abc".into(),
value: None,
timestamp,
};
assert_eq!(
serde_json::to_string(&op)?,
format!(
r#"{{"Update":{{"uuid":"{}","property":"abc","value":null,"timestamp":"{:?}"}}}}"#,
uuid, timestamp,
),
);
Ok(())
}
fn uuid_strategy() -> impl Strategy<Value = Uuid> { fn uuid_strategy() -> impl Strategy<Value = Uuid> {
prop_oneof![ prop_oneof![
Just(Uuid::parse_str("83a2f9ef-f455-4195-b92e-a54c161eebfc").unwrap()), Just(Uuid::parse_str("83a2f9ef-f455-4195-b92e-a54c161eebfc").unwrap()),
@ -349,12 +350,12 @@ mod test {
] ]
} }
fn operation_strategy() -> impl Strategy<Value = Operation> { fn operation_strategy() -> impl Strategy<Value = SyncOp> {
prop_oneof![ prop_oneof![
uuid_strategy().prop_map(|uuid| Operation::Create { uuid }), uuid_strategy().prop_map(|uuid| Create { uuid }),
uuid_strategy().prop_map(|uuid| Operation::Delete { uuid }), uuid_strategy().prop_map(|uuid| Delete { uuid }),
(uuid_strategy(), "(title|project|status)").prop_map(|(uuid, property)| { (uuid_strategy(), "(title|project|status)").prop_map(|(uuid, property)| {
Operation::Update { Update {
uuid, uuid,
property, property,
value: Some("true".into()), value: Some("true".into()),
@ -372,38 +373,38 @@ 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.
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) = SyncOp::transform(o1.clone(), o2.clone());
let mut db1 = TaskDb::new(Box::new(InMemoryStorage::new())); let mut db1 = TaskDb::new(Box::new(InMemoryStorage::new()));
let mut db2 = TaskDb::new(Box::new(InMemoryStorage::new())); let mut db2 = TaskDb::new(Box::new(InMemoryStorage::new()));
// Ensure that any expected tasks already exist // Ensure that any expected tasks already exist
if let Operation::Update{ ref uuid, .. } = o1 { if let Update{ uuid, .. } = o1 {
let _ = db1.apply(Operation::Create{uuid: uuid.clone()}); let _ = db1.apply(Create{uuid});
let _ = db2.apply(Operation::Create{uuid: uuid.clone()}); let _ = db2.apply(Create{uuid});
} }
if let Operation::Update{ ref uuid, .. } = o2 { if let Update{ uuid, .. } = o2 {
let _ = db1.apply(Operation::Create{uuid: uuid.clone()}); let _ = db1.apply(Create{uuid});
let _ = db2.apply(Operation::Create{uuid: uuid.clone()}); let _ = db2.apply(Create{uuid});
} }
if let Operation::Delete{ ref uuid } = o1 { if let Delete{ uuid } = o1 {
let _ = db1.apply(Operation::Create{uuid: uuid.clone()}); let _ = db1.apply(Create{uuid});
let _ = db2.apply(Operation::Create{uuid: uuid.clone()}); let _ = db2.apply(Create{uuid});
} }
if let Operation::Delete{ ref uuid } = o2 { if let Delete{ uuid } = o2 {
let _ = db1.apply(Operation::Create{uuid: uuid.clone()}); let _ = db1.apply(Create{uuid});
let _ = db2.apply(Operation::Create{uuid: uuid.clone()}); let _ = db2.apply(Create{uuid});
} }
// if applying the initial operations fail, that indicates the operation was invalid // if applying the initial operations fail, that indicates the operation was invalid
// in the base state, so consider the case successful. // in the base state, so consider the case successful.
if let Err(_) = db1.apply(o1) { if db1.apply(o1).is_err() {
return Ok(()); return Ok(());
} }
if let Err(_) = db2.apply(o2) { if db2.apply(o2).is_err() {
return Ok(()); return Ok(());
} }

View file

@ -1,6 +1,6 @@
#![allow(clippy::new_without_default)] #![allow(clippy::new_without_default)]
use crate::storage::{Operation, Storage, StorageTxn, TaskMap, VersionId, DEFAULT_BASE_VERSION}; use crate::storage::{ReplicaOp, Storage, StorageTxn, TaskMap, VersionId, DEFAULT_BASE_VERSION};
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;
@ -9,7 +9,7 @@ use uuid::Uuid;
struct Data { struct Data {
tasks: HashMap<Uuid, TaskMap>, tasks: HashMap<Uuid, TaskMap>,
base_version: VersionId, base_version: VersionId,
operations: Vec<Operation>, operations: Vec<ReplicaOp>,
working_set: Vec<Option<Uuid>>, working_set: Vec<Option<Uuid>>,
} }
@ -87,16 +87,16 @@ impl<'t> StorageTxn for Txn<'t> {
Ok(()) Ok(())
} }
fn operations(&mut self) -> anyhow::Result<Vec<Operation>> { fn operations(&mut self) -> anyhow::Result<Vec<ReplicaOp>> {
Ok(self.data_ref().operations.clone()) Ok(self.data_ref().operations.clone())
} }
fn add_operation(&mut self, op: Operation) -> anyhow::Result<()> { fn add_operation(&mut self, op: ReplicaOp) -> anyhow::Result<()> {
self.mut_data_ref().operations.push(op); self.mut_data_ref().operations.push(op);
Ok(()) Ok(())
} }
fn set_operations(&mut self, ops: Vec<Operation>) -> anyhow::Result<()> { fn set_operations(&mut self, ops: Vec<ReplicaOp>) -> anyhow::Result<()> {
self.mut_data_ref().operations = ops; self.mut_data_ref().operations = ops;
Ok(()) Ok(())
} }

View file

@ -11,14 +11,14 @@ use uuid::Uuid;
mod config; mod config;
mod inmemory; mod inmemory;
mod operation; mod op;
pub(crate) mod sqlite; pub(crate) mod sqlite;
pub use config::StorageConfig; pub use config::StorageConfig;
pub use inmemory::InMemoryStorage; pub use inmemory::InMemoryStorage;
pub use sqlite::SqliteStorage; pub use sqlite::SqliteStorage;
pub use operation::Operation; pub use op::ReplicaOp;
/// An in-memory representation of a task as a simple hashmap /// An in-memory representation of a task as a simple hashmap
pub type TaskMap = HashMap<String, String>; pub type TaskMap = HashMap<String, String>;
@ -80,14 +80,14 @@ pub trait StorageTxn {
/// Get the current set of outstanding operations (operations that have not been sync'd to the /// Get the current set of outstanding operations (operations that have not been sync'd to the
/// server yet) /// server yet)
fn operations(&mut self) -> Result<Vec<Operation>>; fn operations(&mut self) -> Result<Vec<ReplicaOp>>;
/// Add an operation to the end of the list of operations in the storage. Note that this /// Add an operation to the end of the list of operations in the storage. Note that this
/// merely *stores* the operation; it is up to the TaskDb to apply it. /// merely *stores* the operation; it is up to the TaskDb to apply it.
fn add_operation(&mut self, op: Operation) -> Result<()>; fn add_operation(&mut self, op: ReplicaOp) -> Result<()>;
/// Replace the current list of operations with a new list. /// Replace the current list of operations with a new list.
fn set_operations(&mut self, ops: Vec<Operation>) -> Result<()>; fn set_operations(&mut self, ops: Vec<ReplicaOp>) -> Result<()>;
/// Get the entire working set, with each task UUID at its appropriate (1-based) index. /// Get the entire working set, with each task UUID at its appropriate (1-based) index.
/// Element 0 is always None. /// Element 0 is always None.

View file

@ -0,0 +1,283 @@
use crate::server::SyncOp;
use crate::storage::TaskMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A ReplicaOp defines a single change to the task database, as stored locally in the replica.
/// This contains additional information not included in SyncOp.
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub enum ReplicaOp {
/// Create a new task.
///
/// On undo, the task is deleted.
Create { uuid: Uuid },
/// Delete an existing task.
///
/// On undo, the task's data is restored from old_task.
Delete { uuid: Uuid, old_task: TaskMap },
/// Update an existing task, setting the given property to the given value. If the value is
/// None, then the corresponding property is deleted.
///
/// On undo, the property is set back to its previous value.
Update {
uuid: Uuid,
property: String,
old_value: Option<String>,
value: Option<String>,
timestamp: DateTime<Utc>,
},
/// Mark a point in the operations history to which the user might like to undo. Users
/// typically want to undo more than one operation at a time (for example, most changes update
/// both the `modified` property and some other task property -- the user would like to "undo"
/// both updates at the same time). Applying an UndoPoint does nothing.
UndoPoint,
}
impl ReplicaOp {
/// Convert this operation into a [`SyncOp`].
pub fn into_sync(self) -> Option<SyncOp> {
match self {
Self::Create { uuid } => Some(SyncOp::Create { uuid }),
Self::Delete { uuid, .. } => Some(SyncOp::Delete { uuid }),
Self::Update {
uuid,
property,
value,
timestamp,
..
} => Some(SyncOp::Update {
uuid,
property,
value,
timestamp,
}),
Self::UndoPoint => None,
}
}
/// Generate a sequence of SyncOp's to reverse the effects of this ReplicaOp.
pub fn reverse_ops(self) -> Vec<SyncOp> {
match self {
Self::Create { uuid } => vec![SyncOp::Delete { uuid }],
Self::Delete { uuid, mut old_task } => {
let mut ops = vec![SyncOp::Create { uuid }];
// We don't have the original update timestamp, but it doesn't
// matter because this SyncOp will just be applied and discarded.
let timestamp = Utc::now();
for (property, value) in old_task.drain() {
ops.push(SyncOp::Update {
uuid,
property,
value: Some(value),
timestamp,
});
}
ops
}
Self::Update {
uuid,
property,
old_value,
timestamp,
..
} => vec![SyncOp::Update {
uuid,
property,
value: old_value,
timestamp,
}],
Self::UndoPoint => vec![],
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::storage::taskmap_with;
use chrono::Utc;
use pretty_assertions::assert_eq;
use ReplicaOp::*;
#[test]
fn test_json_create() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let op = Create { uuid };
let json = serde_json::to_string(&op)?;
assert_eq!(json, format!(r#"{{"Create":{{"uuid":"{}"}}}}"#, uuid));
let deser: ReplicaOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
#[test]
fn test_json_delete() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let old_task = vec![("foo".into(), "bar".into())].drain(..).collect();
let op = Delete { uuid, old_task };
let json = serde_json::to_string(&op)?;
assert_eq!(
json,
format!(
r#"{{"Delete":{{"uuid":"{}","old_task":{{"foo":"bar"}}}}}}"#,
uuid
)
);
let deser: ReplicaOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
#[test]
fn test_json_update() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
let op = Update {
uuid,
property: "abc".into(),
old_value: Some("true".into()),
value: Some("false".into()),
timestamp,
};
let json = serde_json::to_string(&op)?;
assert_eq!(
json,
format!(
r#"{{"Update":{{"uuid":"{}","property":"abc","old_value":"true","value":"false","timestamp":"{:?}"}}}}"#,
uuid, timestamp,
)
);
let deser: ReplicaOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
#[test]
fn test_json_update_none() -> anyhow::Result<()> {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
let op = Update {
uuid,
property: "abc".into(),
old_value: None,
value: None,
timestamp,
};
let json = serde_json::to_string(&op)?;
assert_eq!(
json,
format!(
r#"{{"Update":{{"uuid":"{}","property":"abc","old_value":null,"value":null,"timestamp":"{:?}"}}}}"#,
uuid, timestamp,
)
);
let deser: ReplicaOp = serde_json::from_str(&json)?;
assert_eq!(deser, op);
Ok(())
}
#[test]
fn test_into_sync_create() {
let uuid = Uuid::new_v4();
assert_eq!(Create { uuid }.into_sync(), Some(SyncOp::Create { uuid }));
}
#[test]
fn test_into_sync_delete() {
let uuid = Uuid::new_v4();
assert_eq!(
Delete {
uuid,
old_task: TaskMap::new()
}
.into_sync(),
Some(SyncOp::Delete { uuid })
);
}
#[test]
fn test_into_sync_update() {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
assert_eq!(
Update {
uuid,
property: "prop".into(),
old_value: Some("foo".into()),
value: Some("v".into()),
timestamp,
}
.into_sync(),
Some(SyncOp::Update {
uuid,
property: "prop".into(),
value: Some("v".into()),
timestamp,
})
);
}
#[test]
fn test_into_sync_undo_point() {
assert_eq!(UndoPoint.into_sync(), None);
}
#[test]
fn test_reverse_create() {
let uuid = Uuid::new_v4();
assert_eq!(Create { uuid }.reverse_ops(), vec![SyncOp::Delete { uuid }]);
}
#[test]
fn test_reverse_delete() {
let uuid = Uuid::new_v4();
let reversed = Delete {
uuid,
old_task: taskmap_with(vec![("prop1".into(), "v1".into())]),
}
.reverse_ops();
assert_eq!(reversed.len(), 2);
assert_eq!(reversed[0], SyncOp::Create { uuid });
assert!(matches!(
&reversed[1],
SyncOp::Update { uuid: u, property: p, value: Some(v), ..}
if u == &uuid && p == "prop1" && v == "v1"
));
}
#[test]
fn test_reverse_update() {
let uuid = Uuid::new_v4();
let timestamp = Utc::now();
assert_eq!(
Update {
uuid,
property: "prop".into(),
old_value: Some("foo".into()),
value: Some("v".into()),
timestamp,
}
.reverse_ops(),
vec![SyncOp::Update {
uuid,
property: "prop".into(),
value: Some("foo".into()),
timestamp,
}]
);
}
#[test]
fn test_reverse_undo_point() {
assert_eq!(UndoPoint.reverse_ops(), vec![]);
}
}

View file

@ -1,4 +1,4 @@
use crate::storage::{Operation, Storage, StorageTxn, TaskMap, VersionId, DEFAULT_BASE_VERSION}; use crate::storage::{ReplicaOp, Storage, StorageTxn, TaskMap, VersionId, DEFAULT_BASE_VERSION};
use anyhow::Context; use anyhow::Context;
use rusqlite::types::{FromSql, ToSql}; use rusqlite::types::{FromSql, ToSql};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
@ -52,17 +52,17 @@ impl ToSql for StoredTaskMap {
} }
} }
/// Stores [`Operation`] in SQLite /// Stores [`ReplicaOp`] in SQLite
impl FromSql for Operation { impl FromSql for ReplicaOp {
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> { fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
let o: Operation = serde_json::from_str(value.as_str()?) let o: ReplicaOp = serde_json::from_str(value.as_str()?)
.map_err(|_| rusqlite::types::FromSqlError::InvalidType)?; .map_err(|_| rusqlite::types::FromSqlError::InvalidType)?;
Ok(o) Ok(o)
} }
} }
/// Parsers Operation stored as JSON in string column /// Parses ReplicaOp stored as JSON in string column
impl ToSql for Operation { impl ToSql for ReplicaOp {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> { fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
let s = serde_json::to_string(&self) let s = serde_json::to_string(&self)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
@ -241,12 +241,12 @@ impl<'t> StorageTxn for Txn<'t> {
Ok(()) Ok(())
} }
fn operations(&mut self) -> anyhow::Result<Vec<Operation>> { fn operations(&mut self) -> anyhow::Result<Vec<ReplicaOp>> {
let t = self.get_txn()?; let t = self.get_txn()?;
let mut q = t.prepare("SELECT data FROM operations ORDER BY id ASC")?; let mut q = t.prepare("SELECT data FROM operations ORDER BY id ASC")?;
let rows = q.query_map([], |r| { let rows = q.query_map([], |r| {
let data: Operation = r.get("data")?; let data: ReplicaOp = r.get("data")?;
Ok(data) Ok(data)
})?; })?;
@ -257,7 +257,7 @@ impl<'t> StorageTxn for Txn<'t> {
Ok(ret) Ok(ret)
} }
fn add_operation(&mut self, op: Operation) -> anyhow::Result<()> { fn add_operation(&mut self, op: ReplicaOp) -> anyhow::Result<()> {
let t = self.get_txn()?; let t = self.get_txn()?;
t.execute("INSERT INTO operations (data) VALUES (?)", params![&op]) t.execute("INSERT INTO operations (data) VALUES (?)", params![&op])
@ -265,7 +265,7 @@ impl<'t> StorageTxn for Txn<'t> {
Ok(()) Ok(())
} }
fn set_operations(&mut self, ops: Vec<Operation>) -> anyhow::Result<()> { fn set_operations(&mut self, ops: Vec<ReplicaOp>) -> anyhow::Result<()> {
let t = self.get_txn()?; let t = self.get_txn()?;
t.execute("DELETE FROM operations", []) t.execute("DELETE FROM operations", [])
.context("Clear all existing operations")?; .context("Clear all existing operations")?;
@ -611,8 +611,8 @@ mod test {
// create some operations // create some operations
{ {
let mut txn = storage.txn()?; let mut txn = storage.txn()?;
txn.add_operation(Operation::Create { uuid: uuid1 })?; txn.add_operation(ReplicaOp::Create { uuid: uuid1 })?;
txn.add_operation(Operation::Create { uuid: uuid2 })?; txn.add_operation(ReplicaOp::Create { uuid: uuid2 })?;
txn.commit()?; txn.commit()?;
} }
@ -623,8 +623,8 @@ mod test {
assert_eq!( assert_eq!(
ops, ops,
vec![ vec![
Operation::Create { uuid: uuid1 }, ReplicaOp::Create { uuid: uuid1 },
Operation::Create { uuid: uuid2 }, ReplicaOp::Create { uuid: uuid2 },
] ]
); );
} }
@ -633,8 +633,14 @@ mod test {
{ {
let mut txn = storage.txn()?; let mut txn = storage.txn()?;
txn.set_operations(vec![ txn.set_operations(vec![
Operation::Delete { uuid: uuid2 }, ReplicaOp::Delete {
Operation::Delete { uuid: uuid1 }, uuid: uuid2,
old_task: TaskMap::new(),
},
ReplicaOp::Delete {
uuid: uuid1,
old_task: TaskMap::new(),
},
])?; ])?;
txn.commit()?; txn.commit()?;
} }
@ -642,8 +648,11 @@ mod test {
// create some more operations (to test adding operations after clearing) // create some more operations (to test adding operations after clearing)
{ {
let mut txn = storage.txn()?; let mut txn = storage.txn()?;
txn.add_operation(Operation::Create { uuid: uuid3 })?; txn.add_operation(ReplicaOp::Create { uuid: uuid3 })?;
txn.add_operation(Operation::Delete { uuid: uuid3 })?; txn.add_operation(ReplicaOp::Delete {
uuid: uuid3,
old_task: TaskMap::new(),
})?;
txn.commit()?; txn.commit()?;
} }
@ -654,10 +663,19 @@ mod test {
assert_eq!( assert_eq!(
ops, ops,
vec![ vec![
Operation::Delete { uuid: uuid2 }, ReplicaOp::Delete {
Operation::Delete { uuid: uuid1 }, uuid: uuid2,
Operation::Create { uuid: uuid3 }, old_task: TaskMap::new()
Operation::Delete { uuid: uuid3 }, },
ReplicaOp::Delete {
uuid: uuid1,
old_task: TaskMap::new()
},
ReplicaOp::Create { uuid: uuid3 },
ReplicaOp::Delete {
uuid: uuid3,
old_task: TaskMap::new()
},
] ]
); );
} }

View file

@ -260,10 +260,10 @@ impl<'r> TaskMut<'r> {
fn lastmod(&mut self) -> anyhow::Result<()> { fn lastmod(&mut self) -> anyhow::Result<()> {
if !self.updated_modified { if !self.updated_modified {
let now = format!("{}", Utc::now().timestamp()); let now = format!("{}", Utc::now().timestamp());
self.replica
.update_task(self.task.uuid, "modified", Some(now.clone()))?;
trace!("task {}: set property modified={:?}", self.task.uuid, now); trace!("task {}: set property modified={:?}", self.task.uuid, now);
self.task.taskmap.insert(String::from("modified"), now); self.task.taskmap = self
.replica
.update_task(self.task.uuid, "modified", Some(now))?;
self.updated_modified = true; self.updated_modified = true;
} }
Ok(()) Ok(())
@ -276,16 +276,17 @@ impl<'r> TaskMut<'r> {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let property = property.into(); let property = property.into();
self.lastmod()?; self.lastmod()?;
self.replica
.update_task(self.task.uuid, &property, value.as_ref())?;
if let Some(v) = value { if let Some(ref v) = value {
trace!("task {}: set property {}={:?}", self.task.uuid, property, v); trace!("task {}: set property {}={:?}", self.task.uuid, property, v);
self.task.taskmap.insert(property, v);
} else { } else {
trace!("task {}: remove property {}", self.task.uuid, property); trace!("task {}: remove property {}", self.task.uuid, property);
self.task.taskmap.remove(&property);
} }
self.task.taskmap = self
.replica
.update_task(self.task.uuid, &property, value.as_ref())?;
Ok(()) Ok(())
} }
@ -294,18 +295,7 @@ impl<'r> TaskMut<'r> {
property: &str, property: &str,
value: Option<DateTime<Utc>>, value: Option<DateTime<Utc>>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
self.lastmod()?; self.set_string(property, value.map(|v| v.timestamp().to_string()))
if let Some(value) = value {
let ts = format!("{}", value.timestamp());
self.replica
.update_task(self.task.uuid, property, Some(ts.clone()))?;
self.task.taskmap.insert(property.to_string(), ts);
} else {
self.replica
.update_task::<_, &str>(self.task.uuid, property, None)?;
self.task.taskmap.remove(property);
}
Ok(())
} }
/// Used by tests to ensure that updates are properly written /// Used by tests to ensure that updates are properly written

View file

@ -0,0 +1,399 @@
use crate::errors::Error;
use crate::server::SyncOp;
use crate::storage::{ReplicaOp, StorageTxn, TaskMap};
/// Apply the given SyncOp to the replica, updating both the task data and adding a
/// ReplicaOp to the list of operations. Returns the TaskMap of the task after the
/// operation has been applied (or an empty TaskMap for Delete).
pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow::Result<TaskMap> {
match op {
SyncOp::Create { uuid } => {
let created = txn.create_task(uuid)?;
if created {
txn.add_operation(ReplicaOp::Create { uuid })?;
txn.commit()?;
Ok(TaskMap::new())
} else {
// TODO: differentiate error types here?
Err(Error::Database(format!("Task {} already exists", uuid)).into())
}
}
SyncOp::Delete { uuid } => {
let task = txn.get_task(uuid)?;
if let Some(task) = task {
txn.delete_task(uuid)?;
txn.add_operation(ReplicaOp::Delete {
uuid,
old_task: task,
})?;
txn.commit()?;
Ok(TaskMap::new())
} else {
Err(Error::Database(format!("Task {} does not exist", uuid)).into())
}
}
SyncOp::Update {
uuid,
property,
value,
timestamp,
} => {
let task = txn.get_task(uuid)?;
if let Some(mut task) = task {
let old_value = task.get(&property).cloned();
if let Some(ref v) = value {
task.insert(property.clone(), v.clone());
} else {
task.remove(&property);
}
txn.set_task(uuid, task.clone())?;
txn.add_operation(ReplicaOp::Update {
uuid,
property,
old_value,
value,
timestamp,
})?;
txn.commit()?;
Ok(task)
} else {
Err(Error::Database(format!("Task {} does not exist", uuid)).into())
}
}
}
}
/// Apply an op to the TaskDb's set of tasks (without recording it in the list of operations)
pub(super) fn apply_op(txn: &mut dyn StorageTxn, op: &SyncOp) -> anyhow::Result<()> {
// TODO: test
// TODO: it'd be nice if this was integrated into apply() somehow, but that clones TaskMaps
// unnecessariliy
match op {
SyncOp::Create { uuid } => {
// insert if the task does not already exist
if !txn.create_task(*uuid)? {
return Err(Error::Database(format!("Task {} already exists", uuid)).into());
}
}
SyncOp::Delete { ref uuid } => {
if !txn.delete_task(*uuid)? {
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
SyncOp::Update {
ref uuid,
ref property,
ref value,
timestamp: _,
} => {
// update if this task exists, otherwise ignore
if let Some(mut task) = txn.get_task(*uuid)? {
match value {
Some(ref val) => task.insert(property.to_string(), val.clone()),
None => task.remove(property),
};
txn.set_task(*uuid, task)?;
} else {
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::taskdb::TaskDb;
use chrono::Utc;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use uuid::Uuid;
#[test]
fn test_apply_create() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = SyncOp::Create { uuid };
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op)?;
assert_eq!(taskmap.len(), 0);
txn.commit()?;
}
assert_eq!(db.sorted_tasks(), vec![(uuid, vec![]),]);
assert_eq!(db.operations(), vec![ReplicaOp::Create { uuid }]);
Ok(())
}
#[test]
fn test_apply_create_exists() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = SyncOp::Create { uuid };
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op.clone())?;
assert_eq!(taskmap.len(), 0);
assert_eq!(
apply_and_record(txn.as_mut(), op)
.err()
.unwrap()
.to_string(),
format!("Task Database Error: Task {} already exists", uuid)
);
txn.commit()?;
}
// first op was applied
assert_eq!(db.sorted_tasks(), vec![(uuid, vec![])]);
assert_eq!(db.operations(), vec![ReplicaOp::Create { uuid }]);
Ok(())
}
#[test]
fn test_apply_create_update() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let now = Utc::now();
let op1 = SyncOp::Create { uuid };
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op1)?;
assert_eq!(taskmap.len(), 0);
txn.commit()?;
}
let op2 = SyncOp::Update {
uuid,
property: String::from("title"),
value: Some("my task".into()),
timestamp: now,
};
{
let mut txn = db.storage.txn()?;
let mut taskmap = apply_and_record(txn.as_mut(), op2)?;
assert_eq!(
taskmap.drain().collect::<Vec<(_, _)>>(),
vec![("title".into(), "my task".into())]
);
txn.commit()?;
}
assert_eq!(
db.sorted_tasks(),
vec![(uuid, vec![("title".into(), "my task".into())])]
);
assert_eq!(
db.operations(),
vec![
ReplicaOp::Create { uuid },
ReplicaOp::Update {
uuid,
property: "title".into(),
old_value: None,
value: Some("my task".into()),
timestamp: now
}
]
);
Ok(())
}
#[test]
fn test_apply_create_update_delete_prop() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let now = Utc::now();
let op1 = SyncOp::Create { uuid };
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op1)?;
assert_eq!(taskmap.len(), 0);
txn.commit()?;
}
let op2 = SyncOp::Update {
uuid,
property: String::from("title"),
value: Some("my task".into()),
timestamp: now,
};
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op2)?;
assert_eq!(taskmap.get("title"), Some(&"my task".to_owned()));
txn.commit()?;
}
let op3 = SyncOp::Update {
uuid,
property: String::from("priority"),
value: Some("H".into()),
timestamp: now,
};
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op3)?;
assert_eq!(taskmap.get("priority"), Some(&"H".to_owned()));
txn.commit()?;
}
let op4 = SyncOp::Update {
uuid,
property: String::from("title"),
value: None,
timestamp: now,
};
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op4)?;
assert_eq!(taskmap.get("title"), None);
assert_eq!(taskmap.get("priority"), Some(&"H".to_owned()));
txn.commit()?;
}
let mut exp = HashMap::new();
let mut task = HashMap::new();
task.insert(String::from("priority"), String::from("H"));
exp.insert(uuid, task);
assert_eq!(
db.sorted_tasks(),
vec![(uuid, vec![("priority".into(), "H".into())])]
);
assert_eq!(
db.operations(),
vec![
ReplicaOp::Create { uuid },
ReplicaOp::Update {
uuid,
property: "title".into(),
old_value: None,
value: Some("my task".into()),
timestamp: now,
},
ReplicaOp::Update {
uuid,
property: "priority".into(),
old_value: None,
value: Some("H".into()),
timestamp: now,
},
ReplicaOp::Update {
uuid,
property: "title".into(),
old_value: Some("my task".into()),
value: None,
timestamp: now,
}
]
);
Ok(())
}
#[test]
fn test_apply_update_does_not_exist() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = SyncOp::Update {
uuid,
property: String::from("title"),
value: Some("my task".into()),
timestamp: Utc::now(),
};
{
let mut txn = db.storage.txn()?;
assert_eq!(
apply_and_record(txn.as_mut(), op)
.err()
.unwrap()
.to_string(),
format!("Task Database Error: Task {} does not exist", uuid)
);
txn.commit()?;
}
Ok(())
}
#[test]
fn test_apply_create_delete() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let now = Utc::now();
let op1 = SyncOp::Create { uuid };
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op1)?;
assert_eq!(taskmap.len(), 0);
}
let op2 = SyncOp::Update {
uuid,
property: String::from("priority"),
value: Some("H".into()),
timestamp: now,
};
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op2)?;
assert_eq!(taskmap.get("priority"), Some(&"H".to_owned()));
txn.commit()?;
}
let op3 = SyncOp::Delete { uuid };
{
let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op3)?;
assert_eq!(taskmap.len(), 0);
txn.commit()?;
}
assert_eq!(db.sorted_tasks(), vec![]);
let mut old_task = TaskMap::new();
old_task.insert("priority".into(), "H".into());
assert_eq!(
db.operations(),
vec![
ReplicaOp::Create { uuid },
ReplicaOp::Update {
uuid,
property: "priority".into(),
old_value: None,
value: Some("H".into()),
timestamp: now,
},
ReplicaOp::Delete { uuid, old_task },
]
);
Ok(())
}
#[test]
fn test_apply_delete_not_present() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = SyncOp::Delete { uuid };
{
let mut txn = db.storage.txn()?;
assert_eq!(
apply_and_record(txn.as_mut(), op)
.err()
.unwrap()
.to_string(),
format!("Task Database Error: Task {} does not exist", uuid)
);
txn.commit()?;
}
Ok(())
}
}

View file

@ -1,10 +1,11 @@
use crate::server::Server; use crate::server::{Server, SyncOp};
use crate::storage::{Operation, Storage, TaskMap}; use crate::storage::{ReplicaOp, Storage, TaskMap};
use uuid::Uuid; use uuid::Uuid;
mod ops; mod apply;
mod snapshot; mod snapshot;
mod sync; mod sync;
mod undo;
mod working_set; mod working_set;
/// A TaskDb is the backend for a replica. It manages the storage, operations, synchronization, /// A TaskDb is the backend for a replica. It manages the storage, operations, synchronization,
@ -22,21 +23,29 @@ impl TaskDb {
#[cfg(test)] #[cfg(test)]
pub fn new_inmemory() -> TaskDb { pub fn new_inmemory() -> TaskDb {
TaskDb::new(Box::new(crate::storage::InMemoryStorage::new())) #[cfg(test)]
use crate::storage::InMemoryStorage;
TaskDb::new(Box::new(InMemoryStorage::new()))
} }
/// Apply an operation to the TaskDb. Aside from synchronization operations, this is the only way /// Apply an operation to the TaskDb. This will update the set of tasks and add a ReplicaOp to
/// to modify the TaskDb. In cases where an operation does not make sense, this function will do /// the set of operations in the TaskDb, and return the TaskMap containing the resulting task's
/// nothing and return an error (but leave the TaskDb in a consistent state). /// properties (or an empty TaskMap for deletion).
pub fn apply(&mut self, op: Operation) -> anyhow::Result<()> { ///
// TODO: differentiate error types here? /// Aside from synchronization operations, this is the only way to modify the TaskDb. In cases
/// where an operation does not make sense, this function will do nothing and return an error
/// (but leave the TaskDb in a consistent state).
pub fn apply(&mut self, op: SyncOp) -> anyhow::Result<TaskMap> {
let mut txn = self.storage.txn()?; let mut txn = self.storage.txn()?;
if let err @ Err(_) = ops::apply_op(txn.as_mut(), &op) { apply::apply_and_record(txn.as_mut(), op)
return err; }
}
txn.add_operation(op)?; /// Add an UndoPoint operation to the list of replica operations.
txn.commit()?; pub fn add_undo_point(&mut self) -> anyhow::Result<()> {
Ok(()) let mut txn = self.storage.txn()?;
txn.add_operation(ReplicaOp::UndoPoint)?;
txn.commit()
} }
/// Get all tasks. /// Get all tasks.
@ -112,6 +121,13 @@ impl TaskDb {
sync::sync(server, txn.as_mut(), avoid_snapshots) sync::sync(server, txn.as_mut(), avoid_snapshots)
} }
/// Undo local operations until the most recent UndoPoint, returning false if there are no
/// local operations to undo.
pub fn undo(&mut self) -> anyhow::Result<bool> {
let mut txn = self.storage.txn()?;
undo::undo(txn.as_mut())
}
// functions for supporting tests // functions for supporting tests
#[cfg(test)] #[cfg(test)]
@ -134,7 +150,7 @@ impl TaskDb {
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn operations(&mut self) -> Vec<Operation> { pub(crate) fn operations(&mut self) -> Vec<ReplicaOp> {
let mut txn = self.storage.txn().unwrap(); let mut txn = self.storage.txn().unwrap();
txn.operations() txn.operations()
.unwrap() .unwrap()
@ -148,7 +164,7 @@ impl TaskDb {
mod tests { mod tests {
use super::*; use super::*;
use crate::server::test::TestServer; use crate::server::test::TestServer;
use crate::storage::InMemoryStorage; use crate::storage::{InMemoryStorage, ReplicaOp};
use chrono::Utc; use chrono::Utc;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use proptest::prelude::*; use proptest::prelude::*;
@ -157,14 +173,21 @@ mod tests {
#[test] #[test]
fn test_apply() { fn test_apply() {
// this verifies that the operation is both applied and included in the list of // this verifies that the operation is both applied and included in the list of
// operations; more detailed tests are in the `ops` module. // operations; more detailed tests are in the `apply` module.
let mut db = TaskDb::new_inmemory(); let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let op = Operation::Create { uuid }; let op = SyncOp::Create { uuid };
db.apply(op.clone()).unwrap(); db.apply(op.clone()).unwrap();
assert_eq!(db.sorted_tasks(), vec![(uuid, vec![]),]); assert_eq!(db.sorted_tasks(), vec![(uuid, vec![]),]);
assert_eq!(db.operations(), vec![op]); assert_eq!(db.operations(), vec![ReplicaOp::Create { uuid }]);
}
#[test]
fn test_add_undo_point() {
let mut db = TaskDb::new_inmemory();
db.add_undo_point().unwrap();
assert_eq!(db.operations(), vec![ReplicaOp::UndoPoint]);
} }
fn newdb() -> TaskDb { fn newdb() -> TaskDb {
@ -173,7 +196,7 @@ mod tests {
#[derive(Debug)] #[derive(Debug)]
enum Action { enum Action {
Op(Operation), Op(SyncOp),
Sync, Sync,
} }
@ -185,14 +208,14 @@ mod tests {
.chunks(2) .chunks(2)
.map(|action_on| { .map(|action_on| {
let action = match action_on[0] { let action = match action_on[0] {
b'C' => Action::Op(Operation::Create { uuid }), b'C' => Action::Op(SyncOp::Create { uuid }),
b'U' => Action::Op(Operation::Update { b'U' => Action::Op(SyncOp::Update {
uuid, uuid,
property: "title".into(), property: "title".into(),
value: Some("foo".into()), value: Some("foo".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}), }),
b'D' => Action::Op(Operation::Delete { uuid }), b'D' => Action::Op(SyncOp::Delete { uuid }),
b'S' => Action::Sync, b'S' => Action::Sync,
_ => unreachable!(), _ => unreachable!(),
}; };

View file

@ -1,233 +0,0 @@
use crate::errors::Error;
use crate::storage::{Operation, StorageTxn};
pub(super) fn apply_op(txn: &mut dyn StorageTxn, op: &Operation) -> anyhow::Result<()> {
match op {
Operation::Create { uuid } => {
// insert if the task does not already exist
if !txn.create_task(*uuid)? {
return Err(Error::Database(format!("Task {} already exists", uuid)).into());
}
}
Operation::Delete { ref uuid } => {
if !txn.delete_task(*uuid)? {
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
Operation::Update {
ref uuid,
ref property,
ref value,
timestamp: _,
} => {
// update if this task exists, otherwise ignore
if let Some(mut task) = txn.get_task(*uuid)? {
match value {
Some(ref val) => task.insert(property.to_string(), val.clone()),
None => task.remove(property),
};
txn.set_task(*uuid, task)?;
} else {
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::taskdb::TaskDb;
use chrono::Utc;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use uuid::Uuid;
#[test]
fn test_apply_create() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = Operation::Create { uuid };
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op)?;
txn.commit()?;
}
assert_eq!(db.sorted_tasks(), vec![(uuid, vec![]),]);
Ok(())
}
#[test]
fn test_apply_create_exists() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = Operation::Create { uuid };
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op)?;
assert_eq!(
apply_op(txn.as_mut(), &op).err().unwrap().to_string(),
format!("Task Database Error: Task {} already exists", uuid)
);
txn.commit()?;
}
// first op was applied
assert_eq!(db.sorted_tasks(), vec![(uuid, vec![])]);
Ok(())
}
#[test]
fn test_apply_create_update() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op1 = Operation::Create { uuid };
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op1)?;
txn.commit()?;
}
let op2 = Operation::Update {
uuid,
property: String::from("title"),
value: Some("my task".into()),
timestamp: Utc::now(),
};
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op2)?;
txn.commit()?;
}
assert_eq!(
db.sorted_tasks(),
vec![(uuid, vec![("title".into(), "my task".into())])]
);
Ok(())
}
#[test]
fn test_apply_create_update_delete_prop() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op1 = Operation::Create { uuid };
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op1)?;
txn.commit()?;
}
let op2 = Operation::Update {
uuid,
property: String::from("title"),
value: Some("my task".into()),
timestamp: Utc::now(),
};
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op2)?;
txn.commit()?;
}
let op3 = Operation::Update {
uuid,
property: String::from("priority"),
value: Some("H".into()),
timestamp: Utc::now(),
};
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op3)?;
txn.commit()?;
}
let op4 = Operation::Update {
uuid,
property: String::from("title"),
value: None,
timestamp: Utc::now(),
};
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op4)?;
txn.commit()?;
}
let mut exp = HashMap::new();
let mut task = HashMap::new();
task.insert(String::from("priority"), String::from("H"));
exp.insert(uuid, task);
assert_eq!(
db.sorted_tasks(),
vec![(uuid, vec![("priority".into(), "H".into())])]
);
Ok(())
}
#[test]
fn test_apply_update_does_not_exist() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = Operation::Update {
uuid,
property: String::from("title"),
value: Some("my task".into()),
timestamp: Utc::now(),
};
{
let mut txn = db.storage.txn()?;
assert_eq!(
apply_op(txn.as_mut(), &op).err().unwrap().to_string(),
format!("Task Database Error: Task {} does not exist", uuid)
);
txn.commit()?;
}
Ok(())
}
#[test]
fn test_apply_create_delete() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op1 = Operation::Create { uuid };
let op2 = Operation::Delete { uuid };
{
let mut txn = db.storage.txn()?;
apply_op(txn.as_mut(), &op1)?;
apply_op(txn.as_mut(), &op2)?;
txn.commit()?;
}
assert_eq!(db.sorted_tasks(), vec![]);
Ok(())
}
#[test]
fn test_apply_delete_not_present() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4();
let op = Operation::Delete { uuid };
{
let mut txn = db.storage.txn()?;
assert_eq!(
apply_op(txn.as_mut(), &op).err().unwrap().to_string(),
format!("Task Database Error: Task {} does not exist", uuid)
);
txn.commit()?;
}
Ok(())
}
}

View file

@ -1,6 +1,6 @@
use super::{ops, snapshot}; use super::{apply, snapshot};
use crate::server::{AddVersionResult, GetVersionResult, Server, SnapshotUrgency}; use crate::server::{AddVersionResult, GetVersionResult, Server, SnapshotUrgency, SyncOp};
use crate::storage::{Operation, StorageTxn}; use crate::storage::StorageTxn;
use crate::Error; use crate::Error;
use log::{info, trace, warn}; use log::{info, trace, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -8,7 +8,7 @@ use std::str;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
struct Version { struct Version {
operations: Vec<Operation>, operations: Vec<SyncOp>,
} }
/// Sync to the given server, pulling remote changes and pushing local changes. /// Sync to the given server, pulling remote changes and pushing local changes.
@ -34,6 +34,12 @@ pub(super) fn sync(
trace!("beginning sync outer loop"); trace!("beginning sync outer loop");
let mut base_version_id = txn.base_version()?; let mut base_version_id = txn.base_version()?;
let mut local_ops: Vec<SyncOp> = txn
.operations()?
.drain(..)
.filter_map(|op| op.into_sync())
.collect();
// first pull changes and "rebase" on top of them // first pull changes and "rebase" on top of them
loop { loop {
trace!("beginning sync inner loop"); trace!("beginning sync inner loop");
@ -48,7 +54,7 @@ pub(super) fn sync(
// apply this verison and update base_version in storage // apply this verison and update base_version in storage
info!("applying version {:?} from server", version_id); info!("applying version {:?} from server", version_id);
apply_version(txn, version)?; apply_version(txn, &mut local_ops, version)?;
txn.set_base_version(version_id)?; txn.set_base_version(version_id)?;
base_version_id = version_id; base_version_id = version_id;
} else { } else {
@ -58,17 +64,18 @@ pub(super) fn sync(
} }
} }
let operations: Vec<Operation> = txn.operations()?.to_vec(); if local_ops.is_empty() {
if operations.is_empty() {
info!("no changes to push to server"); info!("no changes to push to server");
// nothing to sync back to the server.. // nothing to sync back to the server..
break; break;
} }
trace!("sending {} operations to the server", operations.len()); trace!("sending {} operations to the server", local_ops.len());
// now make a version of our local changes and push those // now make a version of our local changes and push those
let new_version = Version { operations }; let new_version = Version {
operations: local_ops,
};
let history_segment = serde_json::to_string(&new_version).unwrap().into(); let history_segment = serde_json::to_string(&new_version).unwrap().into();
info!("sending new version to server"); info!("sending new version to server");
let (res, snapshot_urgency) = server.add_version(base_version_id, history_segment)?; let (res, snapshot_urgency) = server.add_version(base_version_id, history_segment)?;
@ -76,7 +83,6 @@ pub(super) fn sync(
AddVersionResult::Ok(new_version_id) => { AddVersionResult::Ok(new_version_id) => {
info!("version {:?} received by server", new_version_id); info!("version {:?} received by server", new_version_id);
txn.set_base_version(new_version_id)?; txn.set_base_version(new_version_id)?;
txn.set_operations(vec![])?;
// make a snapshot if the server indicates it is urgent enough // make a snapshot if the server indicates it is urgent enough
let base_urgency = if avoid_snapshots { let base_urgency = if avoid_snapshots {
@ -106,11 +112,16 @@ pub(super) fn sync(
} }
} }
txn.set_operations(vec![])?;
txn.commit()?; txn.commit()?;
Ok(()) Ok(())
} }
fn apply_version(txn: &mut dyn StorageTxn, mut version: Version) -> anyhow::Result<()> { fn apply_version(
txn: &mut dyn StorageTxn,
local_ops: &mut Vec<SyncOp>,
mut version: Version,
) -> anyhow::Result<()> {
// The situation here is that the server has already applied all server operations, and we // The situation here is that the server has already applied all server operations, and we
// have already applied all local operations, so states have diverged by several // have already applied all local operations, so states have diverged by several
// operations. We need to figure out what operations to apply locally and on the server in // operations. We need to figure out what operations to apply locally and on the server in
@ -136,17 +147,16 @@ fn apply_version(txn: &mut dyn StorageTxn, mut version: Version) -> anyhow::Resu
// This is slightly complicated by the fact that the transform function can return None, // This is slightly complicated by the fact that the transform function can return None,
// indicating no operation is required. If this happens for a local op, we can just omit // indicating no operation is required. If this happens for a local op, we can just omit
// it. If it happens for server op, then we must copy the remaining local ops. // it. If it happens for server op, then we must copy the remaining local ops.
let mut local_operations: Vec<Operation> = txn.operations()?;
for server_op in version.operations.drain(..) { for server_op in version.operations.drain(..) {
trace!( trace!(
"rebasing local operations onto server operation {:?}", "rebasing local operations onto server operation {:?}",
server_op server_op
); );
let mut new_local_ops = Vec::with_capacity(local_operations.len()); let mut new_local_ops = Vec::with_capacity(local_ops.len());
let mut svr_op = Some(server_op); let mut svr_op = Some(server_op);
for local_op in local_operations.drain(..) { for local_op in local_ops.drain(..) {
if let Some(o) = svr_op { if let Some(o) = svr_op {
let (new_server_op, new_local_op) = Operation::transform(o, local_op.clone()); let (new_server_op, new_local_op) = SyncOp::transform(o, local_op.clone());
trace!("local operation {:?} -> {:?}", local_op, new_local_op); trace!("local operation {:?} -> {:?}", local_op, new_local_op);
svr_op = new_server_op; svr_op = new_server_op;
if let Some(o) = new_local_op { if let Some(o) = new_local_op {
@ -161,21 +171,20 @@ fn apply_version(txn: &mut dyn StorageTxn, mut version: Version) -> anyhow::Resu
} }
} }
if let Some(o) = svr_op { if let Some(o) = svr_op {
if let Err(e) = ops::apply_op(txn, &o) { if let Err(e) = apply::apply_op(txn, &o) {
warn!("Invalid operation when syncing: {} (ignored)", e); warn!("Invalid operation when syncing: {} (ignored)", e);
} }
} }
local_operations = new_local_ops; *local_ops = new_local_ops;
} }
txn.set_operations(local_operations)?;
Ok(()) Ok(())
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::server::test::TestServer; use crate::server::{test::TestServer, SyncOp};
use crate::storage::{InMemoryStorage, Operation}; use crate::storage::InMemoryStorage;
use crate::taskdb::{snapshot::SnapshotTasks, TaskDb}; use crate::taskdb::{snapshot::SnapshotTasks, TaskDb};
use chrono::Utc; use chrono::Utc;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@ -197,8 +206,8 @@ mod test {
// 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 }).unwrap(); db1.apply(SyncOp::Create { uuid: uuid1 }).unwrap();
db1.apply(Operation::Update { db1.apply(SyncOp::Update {
uuid: uuid1, uuid: uuid1,
property: "title".into(), property: "title".into(),
value: Some("my first task".into()), value: Some("my first task".into()),
@ -207,8 +216,8 @@ mod test {
.unwrap(); .unwrap();
let uuid2 = Uuid::new_v4(); let uuid2 = Uuid::new_v4();
db2.apply(Operation::Create { uuid: uuid2 }).unwrap(); db2.apply(SyncOp::Create { uuid: uuid2 }).unwrap();
db2.apply(Operation::Update { db2.apply(SyncOp::Update {
uuid: uuid2, uuid: uuid2,
property: "title".into(), property: "title".into(),
value: Some("my second task".into()), value: Some("my second task".into()),
@ -223,14 +232,14 @@ mod test {
assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); assert_eq!(db1.sorted_tasks(), db2.sorted_tasks());
// now make updates to the same task on both sides // now make updates to the same task on both sides
db1.apply(Operation::Update { db1.apply(SyncOp::Update {
uuid: uuid2, uuid: uuid2,
property: "priority".into(), property: "priority".into(),
value: Some("H".into()), value: Some("H".into()),
timestamp: Utc::now(), timestamp: Utc::now(),
}) })
.unwrap(); .unwrap();
db2.apply(Operation::Update { db2.apply(SyncOp::Update {
uuid: uuid2, uuid: uuid2,
property: "project".into(), property: "project".into(),
value: Some("personal".into()), value: Some("personal".into()),
@ -259,8 +268,8 @@ mod test {
// create and update a task.. // create and update a task..
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
db1.apply(Operation::Create { uuid }).unwrap(); db1.apply(SyncOp::Create { uuid }).unwrap();
db1.apply(Operation::Update { db1.apply(SyncOp::Update {
uuid, uuid,
property: "title".into(), property: "title".into(),
value: Some("my first task".into()), value: Some("my first task".into()),
@ -275,9 +284,9 @@ mod test {
assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); assert_eq!(db1.sorted_tasks(), db2.sorted_tasks());
// delete and re-create the task on db1 // delete and re-create the task on db1
db1.apply(Operation::Delete { uuid }).unwrap(); db1.apply(SyncOp::Delete { uuid }).unwrap();
db1.apply(Operation::Create { uuid }).unwrap(); db1.apply(SyncOp::Create { uuid }).unwrap();
db1.apply(Operation::Update { db1.apply(SyncOp::Update {
uuid, uuid,
property: "title".into(), property: "title".into(),
value: Some("my second task".into()), value: Some("my second task".into()),
@ -286,7 +295,7 @@ mod test {
.unwrap(); .unwrap();
// and on db2, update a property of the task // and on db2, update a property of the task
db2.apply(Operation::Update { db2.apply(SyncOp::Update {
uuid, uuid,
property: "project".into(), property: "project".into(),
value: Some("personal".into()), value: Some("personal".into()),
@ -310,8 +319,8 @@ mod test {
let mut db1 = newdb(); let mut db1 = newdb();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
db1.apply(Operation::Create { uuid })?; db1.apply(SyncOp::Create { uuid })?;
db1.apply(Operation::Update { db1.apply(SyncOp::Update {
uuid, uuid,
property: "title".into(), property: "title".into(),
value: Some("my first task".into()), value: Some("my first task".into()),
@ -332,7 +341,7 @@ mod test {
assert_eq!(tasks[0].0, uuid); assert_eq!(tasks[0].0, uuid);
// update the taskdb and sync again // update the taskdb and sync again
db1.apply(Operation::Update { db1.apply(SyncOp::Update {
uuid, uuid,
property: "title".into(), property: "title".into(),
value: Some("my first task, updated".into()), value: Some("my first task, updated".into()),
@ -362,7 +371,7 @@ mod test {
let mut db1 = newdb(); let mut db1 = newdb();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
db1.apply(Operation::Create { uuid }).unwrap(); db1.apply(SyncOp::Create { uuid }).unwrap();
test_server.set_snapshot_urgency(SnapshotUrgency::Low); test_server.set_snapshot_urgency(SnapshotUrgency::Low);
sync(&mut server, db1.storage.txn()?.as_mut(), true).unwrap(); sync(&mut server, db1.storage.txn()?.as_mut(), true).unwrap();

View file

@ -0,0 +1,117 @@
use super::apply;
use crate::storage::{ReplicaOp, StorageTxn};
use log::{debug, trace};
/// Undo local operations until an UndoPoint.
pub(super) fn undo(txn: &mut dyn StorageTxn) -> anyhow::Result<bool> {
let mut applied = false;
let mut popped = false;
let mut local_ops = txn.operations()?;
while let Some(op) = local_ops.pop() {
popped = true;
if op == ReplicaOp::UndoPoint {
break;
}
debug!("Reversing operation {:?}", op);
let rev_ops = op.reverse_ops();
for op in rev_ops {
trace!("Applying reversed operation {:?}", op);
apply::apply_op(txn, &op)?;
applied = true;
}
}
if popped {
txn.set_operations(local_ops)?;
txn.commit()?;
}
Ok(applied)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::SyncOp;
use crate::taskdb::TaskDb;
use chrono::Utc;
use pretty_assertions::assert_eq;
use uuid::Uuid;
#[test]
fn test_apply_create() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory();
let uuid1 = Uuid::new_v4();
let uuid2 = Uuid::new_v4();
let timestamp = Utc::now();
// apply a few ops, capture the DB state, make an undo point, and then apply a few more
// ops.
db.apply(SyncOp::Create { uuid: uuid1 })?;
db.apply(SyncOp::Update {
uuid: uuid1,
property: "prop".into(),
value: Some("v1".into()),
timestamp,
})?;
db.apply(SyncOp::Create { uuid: uuid2 })?;
db.apply(SyncOp::Update {
uuid: uuid2,
property: "prop".into(),
value: Some("v2".into()),
timestamp,
})?;
db.apply(SyncOp::Update {
uuid: uuid2,
property: "prop2".into(),
value: Some("v3".into()),
timestamp,
})?;
let db_state = db.sorted_tasks();
db.add_undo_point()?;
db.apply(SyncOp::Delete { uuid: uuid1 })?;
db.apply(SyncOp::Update {
uuid: uuid2,
property: "prop".into(),
value: None,
timestamp,
})?;
db.apply(SyncOp::Update {
uuid: uuid2,
property: "prop2".into(),
value: Some("new-value".into()),
timestamp,
})?;
assert_eq!(db.operations().len(), 9);
{
let mut txn = db.storage.txn()?;
assert!(undo(txn.as_mut())?);
}
// undo took db back to the snapshot
assert_eq!(db.operations().len(), 5);
assert_eq!(db.sorted_tasks(), db_state);
{
let mut txn = db.storage.txn()?;
assert!(undo(txn.as_mut())?);
}
// empty db
assert_eq!(db.operations().len(), 0);
assert_eq!(db.sorted_tasks(), vec![]);
{
let mut txn = db.storage.txn()?;
// nothing left to undo, so undo() returns false
assert!(!undo(txn.as_mut())?);
}
Ok(())
}
}

View file

@ -63,7 +63,7 @@ where
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::storage::Operation; use crate::server::SyncOp;
use crate::taskdb::TaskDb; use crate::taskdb::TaskDb;
use chrono::Utc; use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
@ -94,10 +94,10 @@ mod test {
// add everything to the TaskDb // add everything to the TaskDb
for uuid in &uuids { for uuid in &uuids {
db.apply(Operation::Create { uuid: *uuid })?; db.apply(SyncOp::Create { uuid: *uuid })?;
} }
for i in &[0usize, 1, 4] { for i in &[0usize, 1, 4] {
db.apply(Operation::Update { db.apply(SyncOp::Update {
uuid: uuids[*i].clone(), uuid: uuids[*i].clone(),
property: String::from("status"), property: String::from("status"),
value: Some("pending".into()), value: Some("pending".into()),