diff --git a/src/core/etag_store.rs b/src/core/etag_store.rs new file mode 100644 index 0000000..03c47a8 --- /dev/null +++ b/src/core/etag_store.rs @@ -0,0 +1,100 @@ +//! Persistence of the per-folder root ETag (issue #195). +//! +//! The ETag gate (#189) skips the periodic remote reconciliation when the +//! root ETag is unchanged. That ETag used to live only in the engine's +//! in-memory `etag_slot`, so after a restart the first interval of every +//! folder re-scanned even with zero remote changes. This module persists the +//! ETag per folder in the app's state directory (`state_dir()/etags/`), +//! outside the synced folder (writing inside the folder would be seen by the +//! filesystem watcher and could requeue the folder, issue #181). +//! +//! Reads and writes are best-effort: a missing/stale value degrades to "the +//! ETag changed", which is the safe direction (it reconciles). + +use std::path::{Path, PathBuf}; + +use crate::util::paths::state_dir; + +/// Where the ETag of one folder lives, keyed by folder id (unique per folder, +/// stable across restarts). The base directory is the state dir. +pub fn etag_path(folder_id: &str) -> PathBuf { + etag_path_in(&state_dir(), folder_id) +} + +/// The ETag file path under a given base directory (test-injectable). +fn etag_path_in(base: &Path, folder_id: &str) -> PathBuf { + base.join("etags").join(folder_id) +} + +/// Read the last recorded ETag for `folder_id`, if any. +pub fn read_etag(folder_id: &str) -> Option { + read_etag_in(&state_dir(), folder_id) +} + +fn read_etag_in(base: &Path, folder_id: &str) -> Option { + std::fs::read_to_string(etag_path_in(base, folder_id)) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Record the ETag for `folder_id`. Best-effort: failures are ignored (the +/// gate still works, it just re-scans once more next run). +pub fn write_etag(folder_id: &str, etag: &str) { + write_etag_in(&state_dir(), folder_id, etag); +} + +fn write_etag_in(base: &Path, folder_id: &str, etag: &str) { + if etag.is_empty() { + return; + } + let path = etag_path_in(base, folder_id); + let _ = path.parent().map(std::fs::create_dir_all); + let _ = std::fs::write(&path, etag); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_at(base: &Path, folder_id: &str, value: &str) { + let path = etag_path_in(base, folder_id); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, value).unwrap(); + } + + #[test] + fn read_returns_the_recorded_etag() { + let dir = tempfile::tempdir().unwrap(); + write_at(dir.path(), "folder-1", "\"abc\"\n"); + assert_eq!( + read_etag_in(dir.path(), "folder-1").as_deref(), + Some("\"abc\"") + ); + } + + #[test] + fn missing_or_empty_etag_yields_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(read_etag_in(dir.path(), "folder-1"), None); + write_at(dir.path(), "folder-1", " \n"); + assert_eq!(read_etag_in(dir.path(), "folder-1"), None); + } + + #[test] + fn write_then_read_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + write_etag_in(dir.path(), "folder-2", "\"xyz\""); + assert_eq!( + read_etag_in(dir.path(), "folder-2").as_deref(), + Some("\"xyz\"") + ); + } + + #[test] + fn empty_etag_is_not_written() { + let dir = tempfile::tempdir().unwrap(); + write_etag_in(dir.path(), "folder-3", ""); + assert!(!etag_path_in(dir.path(), "folder-3").exists()); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 35d8533..73bebab 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -7,6 +7,7 @@ pub mod conflict_files; pub mod debounce; pub mod delete_guard; pub mod desktop_integration; +pub mod etag_store; pub mod exclusions; pub mod files_journal; pub mod log; diff --git a/src/nextcloud/sync_engine.rs b/src/nextcloud/sync_engine.rs index a5c06b0..5c05dcc 100644 --- a/src/nextcloud/sync_engine.rs +++ b/src/nextcloud/sync_engine.rs @@ -263,6 +263,11 @@ pub struct SyncEngine { etag_slot: Arc>>, /// Issue #189: reads the folder root ETag before a periodic interval run. etag_probe: Option, + /// Issue #195: called on the main thread when a periodic interval run + /// records a new root ETag for this folder, so the caller can persist it + /// across restarts (avoiding the first-run re-scan). Best-effort: the + /// engine never blocks on this. + on_etag_change: Option>, } impl SyncEngine { @@ -279,6 +284,11 @@ impl SyncEngine { executable: Option, progress: async_channel::Sender, ) -> Self { + // Issue #195: seed the ETag slot from the persisted value so the first + // periodic interval after a restart skips the reconciliation when the + // remote tree is unchanged (avoids the full re-scan). + let folder_id = folder.id.clone(); + let seeded_etag = crate::core::etag_store::read_etag(&folder_id); Self { account, folder, @@ -290,8 +300,9 @@ impl SyncEngine { process: Arc::new(Mutex::new(None)), remote_ensurer: None, health_probe: None, - etag_slot: Arc::new(Mutex::new(None)), + etag_slot: Arc::new(Mutex::new(seeded_etag)), etag_probe: None, + on_etag_change: None, } } @@ -321,6 +332,14 @@ impl SyncEngine { self } + /// Install a callback invoked (best-effort) when a periodic interval run + /// records a new root ETag (issue #195). The caller persists it so a + /// restart does not re-scan a folder whose remote tree is unchanged. + pub fn with_on_etag_change(mut self, callback: Arc) -> Self { + self.on_etag_change = Some(callback); + self + } + /// Whether a reconciliation is currently running. pub fn is_running(&self) -> bool { self.process @@ -345,6 +364,7 @@ impl SyncRunner for SyncEngine { let health_probe = self.health_probe.clone(); let etag_slot = Arc::clone(&self.etag_slot); let etag_probe = self.etag_probe.clone(); + let on_etag_change = self.on_etag_change.clone(); let inputs = EngineInputs { account, folder, @@ -356,6 +376,7 @@ impl SyncRunner for SyncEngine { reasons: reasons.to_vec(), etag_slot, etag_probe, + on_etag_change, }; glib::spawn_future_local(async move { let run = @@ -425,6 +446,8 @@ struct EngineInputs { etag_slot: Arc>>, /// Issue #189: reads the folder root ETag before a periodic interval run. etag_probe: Option, + /// Issue #195: best-effort callback to persist a newly recorded ETag. + on_etag_change: Option>, } /// Run the whole reconciliation on the blocking thread pool. @@ -497,7 +520,14 @@ fn engine_thread( return EngineRun::Direct(SyncOutcome::Success); } // Changed (or first run): record the new ETag and reconcile. - *inputs.etag_slot.lock().unwrap() = Some(fresh_etag); + *inputs.etag_slot.lock().unwrap() = Some(fresh_etag.clone()); + // Issue #195: persist best-effort so the next restart can skip + // the first no-change reconciliation. + crate::core::etag_store::write_etag(&inputs.folder.id, &fresh_etag); + // Issue #195: also surface the change to an optional callback. + if let Some(callback) = inputs.on_etag_change.as_ref() { + callback(fresh_etag); + } } // Ok(None) or Err(_): the server did not answer or the ETag is // unavailable - do NOT skip the reconciliation (a real change diff --git a/src/storage/config.rs b/src/storage/config.rs index 65d1729..57a2718 100644 --- a/src/storage/config.rs +++ b/src/storage/config.rs @@ -212,6 +212,11 @@ impl Default for DeleteGuardConfig { pub struct RuntimeConfig { pub last_successful_sync: Option, pub last_exit_code: Option, + /// Issue #195: last observed root ETag per folder, keyed by the folder's + /// `remote_path`. Persisted so a restart can skip the first no-change + /// reconciliation (the ETag gate #189). Best-effort runtime data. + #[serde(default)] + pub remote_etags: std::collections::HashMap, } /// General (non-account) settings. @@ -1145,6 +1150,18 @@ fn validate_runtime(raw: Option<&Value>) -> RuntimeConfig { .and_then(Value::as_str) .map(str::to_string), last_exit_code: merged.get("last_exit_code").and_then(Value::as_i64), + remote_etags: merged + .get("remote_etags") + .and_then(Value::as_object) + .map(|object| { + object + .iter() + .filter_map(|(key, value)| { + value.as_str().map(|text| (key.clone(), text.to_string())) + }) + .collect() + }) + .unwrap_or_default(), } }