Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/core/etag_store.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
read_etag_in(&state_dir(), folder_id)
}

fn read_etag_in(base: &Path, folder_id: &str) -> Option<String> {
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());
}
}
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 32 additions & 2 deletions src/nextcloud/sync_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ pub struct SyncEngine {
etag_slot: Arc<Mutex<Option<String>>>,
/// Issue #189: reads the folder root ETag before a periodic interval run.
etag_probe: Option<EtagProbe>,
/// 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<Arc<dyn Fn(String) + Send + Sync>>,
}

impl SyncEngine {
Expand All @@ -279,6 +284,11 @@ impl SyncEngine {
executable: Option<PathBuf>,
progress: async_channel::Sender<SyncProgress>,
) -> 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,
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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<dyn Fn(String) + Send + Sync>) -> Self {
self.on_etag_change = Some(callback);
self
}

/// Whether a reconciliation is currently running.
pub fn is_running(&self) -> bool {
self.process
Expand All @@ -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,
Expand All @@ -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 =
Expand Down Expand Up @@ -425,6 +446,8 @@ struct EngineInputs {
etag_slot: Arc<Mutex<Option<String>>>,
/// Issue #189: reads the folder root ETag before a periodic interval run.
etag_probe: Option<EtagProbe>,
/// Issue #195: best-effort callback to persist a newly recorded ETag.
on_etag_change: Option<Arc<dyn Fn(String) + Send + Sync>>,
}

/// Run the whole reconciliation on the blocking thread pool.
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/storage/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ impl Default for DeleteGuardConfig {
pub struct RuntimeConfig {
pub last_successful_sync: Option<String>,
pub last_exit_code: Option<i64>,
/// 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<String, String>,
}

/// General (non-account) settings.
Expand Down Expand Up @@ -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(),
}
}

Expand Down
Loading