diff --git a/taskchampion/src/server/local.rs b/taskchampion/src/server/local.rs index b8c0198a2..37cb06614 100644 --- a/taskchampion/src/server/local.rs +++ b/taskchampion/src/server/local.rs @@ -160,6 +160,10 @@ impl Server for LocalServer { // the local server never requests a snapshot, so it should never get one unreachable!() } + + fn get_snapshot(&mut self) -> anyhow::Result> { + Ok(None) + } } #[cfg(test)] diff --git a/taskchampion/src/server/remote/mod.rs b/taskchampion/src/server/remote/mod.rs index 139f5dc9f..c7d3362e5 100644 --- a/taskchampion/src/server/remote/mod.rs +++ b/taskchampion/src/server/remote/mod.rs @@ -152,4 +152,25 @@ impl Server for RemoteServer { .send_bytes(ciphertext.as_ref()) .map(|_| ())?) } + + fn get_snapshot(&mut self) -> anyhow::Result> { + let url = format!("{}/v1/client/snapshot", self.origin); + match self + .agent + .get(&url) + .set("X-Client-Key", &self.client_key.to_string()) + .call() + { + Ok(resp) => { + let version_id = get_uuid_header(&resp, "X-Version-Id")?; + let ciphertext = Ciphertext::from_resp(resp, SNAPSHOT_CONTENT_TYPE)?; + let snapshot = ciphertext + .open(&self.encryption_secret, version_id)? + .payload; + Ok(Some((version_id, snapshot))) + } + Err(ureq::Error::Status(status, _)) if status == 404 => Ok(None), + Err(err) => Err(err.into()), + } + } } diff --git a/taskchampion/src/server/test.rs b/taskchampion/src/server/test.rs index 3901e2fb6..1fff611cc 100644 --- a/taskchampion/src/server/test.rs +++ b/taskchampion/src/server/test.rs @@ -12,9 +12,8 @@ struct Version { history_segment: HistorySegment, } -#[derive(Clone)] - /// TestServer implements the Server trait with a test implementation. +#[derive(Clone)] pub(crate) struct TestServer(Arc>); pub(crate) struct Inner { @@ -35,6 +34,7 @@ impl TestServer { snapshot: None, }))) } + // feel free to add any test utility functions here /// Get a boxed Server implementation referring to this TestServer pub(crate) fn server(&self) -> Box { @@ -51,6 +51,12 @@ impl TestServer { let inner = self.0.lock().unwrap(); inner.snapshot.as_ref().cloned() } + + /// Delete a version from storage + pub(crate) fn delete_version(&mut self, parent_version_id: VersionId) { + let mut inner = self.0.lock().unwrap(); + inner.versions.remove(&parent_version_id); + } } impl Server for TestServer { @@ -119,4 +125,9 @@ impl Server for TestServer { inner.snapshot = Some((version_id, snapshot)); Ok(()) } + + fn get_snapshot(&mut self) -> anyhow::Result> { + let inner = self.0.lock().unwrap(); + Ok(inner.snapshot.clone()) + } } diff --git a/taskchampion/src/server/types.rs b/taskchampion/src/server/types.rs index 3a1178c41..fada6c04a 100644 --- a/taskchampion/src/server/types.rs +++ b/taskchampion/src/server/types.rs @@ -65,4 +65,6 @@ pub trait Server { /// Add a snapshot on the server fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> anyhow::Result<()>; + + fn get_snapshot(&mut self) -> anyhow::Result>; } diff --git a/taskchampion/src/storage/mod.rs b/taskchampion/src/storage/mod.rs index 0b3e3da32..a16bb5a7e 100644 --- a/taskchampion/src/storage/mod.rs +++ b/taskchampion/src/storage/mod.rs @@ -105,6 +105,16 @@ pub trait StorageTxn { /// Note that this is the only way items are removed from the set. fn clear_working_set(&mut self) -> Result<()>; + /// Check whether this storage is entirely empty + fn is_empty(&mut self) -> Result { + let mut empty = true; + empty = empty && self.all_tasks()?.is_empty(); + empty = empty && self.get_working_set()? == vec![None]; + empty = empty && self.base_version()? == Uuid::nil(); + empty = empty && self.operations()?.is_empty(); + Ok(empty) + } + /// Commit any changes made in the transaction. It is an error to call this more than /// once. fn commit(&mut self) -> Result<()>; diff --git a/taskchampion/src/taskdb/snapshot.rs b/taskchampion/src/taskdb/snapshot.rs index e054612b3..33ab7e8df 100644 --- a/taskchampion/src/taskdb/snapshot.rs +++ b/taskchampion/src/taskdb/snapshot.rs @@ -70,7 +70,6 @@ impl SnapshotTasks { } } -#[allow(dead_code)] /// Generate a snapshot (compressed, unencrypted) for the current state of the taskdb in the given /// storage. pub(super) fn make_snapshot(txn: &mut dyn StorageTxn) -> anyhow::Result> { @@ -78,7 +77,6 @@ pub(super) fn make_snapshot(txn: &mut dyn StorageTxn) -> anyhow::Result> all_tasks.encode() } -#[allow(dead_code)] /// Apply the given snapshot (compressed, unencrypted) to the taskdb's storage. pub(super) fn apply_snapshot( txn: &mut dyn StorageTxn, @@ -87,14 +85,8 @@ pub(super) fn apply_snapshot( ) -> anyhow::Result<()> { let all_tasks = SnapshotTasks::decode(snapshot)?; - // first, verify that the taskdb truly is empty - let mut empty = true; - empty = empty && txn.all_tasks()?.is_empty(); - empty = empty && txn.get_working_set()? == vec![None]; - empty = empty && txn.base_version()? == Uuid::nil(); - empty = empty && txn.operations()?.is_empty(); - - if !empty { + // double-check emptiness + if !txn.is_empty()? { anyhow::bail!("Cannot apply snapshot to a non-empty task database"); } diff --git a/taskchampion/src/taskdb/sync.rs b/taskchampion/src/taskdb/sync.rs index 2cd8c2717..e77a5db66 100644 --- a/taskchampion/src/taskdb/sync.rs +++ b/taskchampion/src/taskdb/sync.rs @@ -16,6 +16,15 @@ pub(super) fn sync( txn: &mut dyn StorageTxn, avoid_snapshots: bool, ) -> anyhow::Result<()> { + // if this taskdb is entirely empty, then start by getting and applying a snapshot + if txn.is_empty()? { + trace!("storage is empty; attempting to apply a snapshot"); + if let Some((version, snap)) = server.get_snapshot()? { + snapshot::apply_snapshot(txn, version, snap.as_ref())?; + trace!("applied snapshot for version {}", version); + } + } + // retry synchronizing until the server accepts our version (this allows for races between // replicas trying to sync to the same server). If the server insists on the same base // version twice, then we have diverged. @@ -293,24 +302,23 @@ mod test { } #[test] - fn test_sync_adds_snapshot() -> anyhow::Result<()> { - let test_server = TestServer::new(); + fn test_sync_add_snapshot_start_with_snapshot() -> anyhow::Result<()> { + let mut test_server = TestServer::new(); let mut server: Box = test_server.server(); let mut db1 = newdb(); let uuid = Uuid::new_v4(); - db1.apply(Operation::Create { uuid }).unwrap(); + db1.apply(Operation::Create { uuid })?; db1.apply(Operation::Update { uuid, property: "title".into(), value: Some("my first task".into()), timestamp: Utc::now(), - }) - .unwrap(); + })?; test_server.set_snapshot_urgency(SnapshotUrgency::High); - sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db1.storage.txn()?.as_mut(), false)?; // assert that a snapshot was added let base_version = db1.storage.txn()?.base_version()?; @@ -322,6 +330,26 @@ mod test { let tasks = SnapshotTasks::decode(&s)?.into_inner(); assert_eq!(tasks[0].0, uuid); + // update the taskdb and sync again + db1.apply(Operation::Update { + uuid, + property: "title".into(), + value: Some("my first task, updated".into()), + timestamp: Utc::now(), + })?; + sync(&mut server, db1.storage.txn()?.as_mut(), false)?; + + // delete the first version, so that db2 *must* initialize from + // the snapshot + test_server.delete_version(Uuid::nil()); + + // sync to a new DB and check that we got the expected results + let mut db2 = newdb(); + sync(&mut server, db2.storage.txn()?.as_mut(), false)?; + + let task = db2.get_task(uuid)?.unwrap(); + assert_eq!(task.get("title").unwrap(), "my first task, updated"); + Ok(()) }