mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
sync refactor
This commit is contained in:
1 parent
0a1fa95eb4
commit
bef39f75cf
2 files changed
+248
-106
No files matched your search
+71
-106
@@ -56,8 +56,8 @@ use local_scan::open_for_write_set;
|
|||||||
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
|
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
|
||||||
pub(crate) use planner::base_name;
|
pub(crate) use planner::base_name;
|
||||||
use planner::{
|
use planner::{
|
||||||
classification_order, conflict_path, filter_baseline, guard_local_wipe, join_rel, parent_rel,
|
FilePlan, classification_order, conflict_path, filter_baseline, guard_local_wipe, join_rel,
|
||||||
rel_to_path, remote_sig, unchanged_remote_size,
|
parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
|
||||||
};
|
};
|
||||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||||
/// engine thread (never inside a task).
|
/// engine thread (never inside a task).
|
||||||
@@ -666,117 +666,82 @@ impl Core {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// File.
|
match plan_file(l, r, baseline.get(rel)) {
|
||||||
// A file still open for writing is treated as unchanged on the local
|
FilePlan::Unchanged => {}
|
||||||
// side: uploading it now would capture a torn snapshot, and the next
|
FilePlan::Deferred => {
|
||||||
// pass — after the writer has closed it — will see the final content.
|
debug!(
|
||||||
if l.is_some_and(|l| l.open_for_write) {
|
rel,
|
||||||
debug!(
|
"sync: file open for write by another process; deferring upload"
|
||||||
rel,
|
);
|
||||||
"sync: file open for write by another process; deferring upload"
|
outcome.deferred += 1;
|
||||||
);
|
|
||||||
outcome.deferred += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let base = baseline.get(rel);
|
|
||||||
let local_changed = l.is_some_and(|l| {
|
|
||||||
base.is_none_or(|b| b.local_mtime != l.mtime || b.local_size != l.size)
|
|
||||||
});
|
|
||||||
let remote_changed =
|
|
||||||
r.is_some_and(|r| base.is_none_or(|b| remote_sig(b) != Some((r.mtime, r.size))));
|
|
||||||
|
|
||||||
match (l, r) {
|
|
||||||
(Some(_), Some(r)) => {
|
|
||||||
// With no baseline, both sides read as "changed" and fall to the
|
|
||||||
// conflict arm — the safe default for a folder re-added over
|
|
||||||
// existing remote content.
|
|
||||||
if !local_changed && !remote_changed {
|
|
||||||
// Both untouched; baseline already matches.
|
|
||||||
} else if local_changed && !remote_changed {
|
|
||||||
batch.push(Pending::UploadRevision {
|
|
||||||
rel: rel.clone(),
|
|
||||||
uid: r.uid.clone(),
|
|
||||||
});
|
|
||||||
} else if remote_changed && !local_changed {
|
|
||||||
batch.push(Pending::Download {
|
|
||||||
rel: rel.clone(),
|
|
||||||
uid: r.uid.clone(),
|
|
||||||
mtime: r.mtime,
|
|
||||||
size: r.size,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
batch.push(Pending::Conflict {
|
|
||||||
rel: rel.clone(),
|
|
||||||
uid: r.uid.clone(),
|
|
||||||
mtime: r.mtime,
|
|
||||||
size: r.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(Some(_), None) => {
|
FilePlan::UploadRevision => {
|
||||||
if base.is_none() || local_changed {
|
let remote = r.expect("upload revision requires a remote file");
|
||||||
// New local file, or remote deleted while local changed →
|
batch.push(Pending::UploadRevision {
|
||||||
// (re)upload it as a new remote file.
|
rel: rel.clone(),
|
||||||
match remote_dirs.get(parent_rel(rel)) {
|
uid: remote.uid.clone(),
|
||||||
Some(parent) => batch.push(Pending::UploadNew {
|
});
|
||||||
rel: rel.clone(),
|
}
|
||||||
parent: parent.clone(),
|
FilePlan::Download => {
|
||||||
}),
|
let remote = r.expect("download requires a remote file");
|
||||||
None => {
|
batch.push(Pending::Download {
|
||||||
warn!(rel, "sync: no remote parent for file; continuing");
|
rel: rel.clone(),
|
||||||
outcome.errors += 1;
|
uid: remote.uid.clone(),
|
||||||
}
|
mtime: remote.mtime,
|
||||||
}
|
size: remote.size,
|
||||||
} else {
|
});
|
||||||
// Remote deleted, local untouched → delete local.
|
}
|
||||||
let _ = std::fs::remove_file(local_root.join(rel_to_path(rel)));
|
FilePlan::Conflict => {
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let remote = r.expect("conflict requires a remote file");
|
||||||
|
batch.push(Pending::Conflict {
|
||||||
|
rel: rel.clone(),
|
||||||
|
uid: remote.uid.clone(),
|
||||||
|
mtime: remote.mtime,
|
||||||
|
size: remote.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FilePlan::UploadNew => match remote_dirs.get(parent_rel(rel)) {
|
||||||
|
Some(parent) => batch.push(Pending::UploadNew {
|
||||||
|
rel: rel.clone(),
|
||||||
|
parent: parent.clone(),
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
warn!(rel, "sync: no remote parent for file; continuing");
|
||||||
|
outcome.errors += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FilePlan::DeleteLocal => {
|
||||||
|
let _ = std::fs::remove_file(local_root.join(rel_to_path(rel)));
|
||||||
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
|
self.log_activity(ActivityKind::Trash, base_name(rel), "removed locally", true);
|
||||||
|
outcome.deleted += 1;
|
||||||
|
}
|
||||||
|
FilePlan::DeleteRemote => {
|
||||||
|
let remote = r.expect("remote deletion requires a remote file");
|
||||||
|
if let Err(e) = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.trash_nodes(std::slice::from_ref(&remote.uid)))
|
||||||
|
{
|
||||||
|
warn!(rel, error = %e, "sync: trash remote failed");
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
base_name(rel),
|
base_name(rel),
|
||||||
"removed locally",
|
e.to_string(),
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
outcome.deleted += 1;
|
outcome.errors += 1;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Trash,
|
||||||
|
base_name(rel),
|
||||||
|
"removed on Drive",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
outcome.deleted += 1;
|
||||||
}
|
}
|
||||||
(None, Some(r)) => {
|
FilePlan::ForgetBaseline => {
|
||||||
if base.is_none() || remote_changed {
|
|
||||||
// New remote file, or local deleted while remote changed →
|
|
||||||
// (re)download it.
|
|
||||||
batch.push(Pending::Download {
|
|
||||||
rel: rel.clone(),
|
|
||||||
uid: r.uid.clone(),
|
|
||||||
mtime: r.mtime,
|
|
||||||
size: r.size,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Local deleted, remote untouched → delete remote.
|
|
||||||
if let Err(e) = self
|
|
||||||
.rt
|
|
||||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&r.uid)))
|
|
||||||
{
|
|
||||||
warn!(rel, error = %e, "sync: trash remote failed");
|
|
||||||
self.log_activity(
|
|
||||||
ActivityKind::Trash,
|
|
||||||
base_name(rel),
|
|
||||||
e.to_string(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
outcome.errors += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
|
||||||
self.log_activity(
|
|
||||||
ActivityKind::Trash,
|
|
||||||
base_name(rel),
|
|
||||||
"removed on Drive",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
outcome.deleted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, None) => {
|
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,3 +174,180 @@ pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
|||||||
None => PathBuf::from(name),
|
None => PathBuf::from(name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pure decision for one file-shaped path. Transfer identities and filesystem
|
||||||
|
/// effects are attached by the executor after this classification.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum FilePlan {
|
||||||
|
Unchanged,
|
||||||
|
Deferred,
|
||||||
|
UploadNew,
|
||||||
|
UploadRevision,
|
||||||
|
Download,
|
||||||
|
Conflict,
|
||||||
|
DeleteLocal,
|
||||||
|
DeleteRemote,
|
||||||
|
ForgetBaseline,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn plan_file(
|
||||||
|
local: Option<&LocalItem>,
|
||||||
|
remote: Option<&RemoteItem>,
|
||||||
|
baseline: Option<&StoredSyncEntry>,
|
||||||
|
) -> FilePlan {
|
||||||
|
if local.is_some_and(|item| item.open_for_write) {
|
||||||
|
return FilePlan::Deferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_changed = local.is_some_and(|item| {
|
||||||
|
baseline.is_none_or(|base| base.local_mtime != item.mtime || base.local_size != item.size)
|
||||||
|
});
|
||||||
|
let remote_changed = remote.is_some_and(|item| {
|
||||||
|
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
||||||
|
});
|
||||||
|
|
||||||
|
match (local, remote) {
|
||||||
|
(Some(_), Some(_)) => match (local_changed, remote_changed) {
|
||||||
|
(false, false) => FilePlan::Unchanged,
|
||||||
|
(true, false) => FilePlan::UploadRevision,
|
||||||
|
(false, true) => FilePlan::Download,
|
||||||
|
(true, true) => FilePlan::Conflict,
|
||||||
|
},
|
||||||
|
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
||||||
|
(Some(_), None) => FilePlan::DeleteLocal,
|
||||||
|
(None, Some(_)) if baseline.is_none() || remote_changed => FilePlan::Download,
|
||||||
|
(None, Some(_)) => FilePlan::DeleteRemote,
|
||||||
|
(None, None) => FilePlan::ForgetBaseline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod file_plan_tests {
|
||||||
|
use super::*;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
|
|
||||||
|
fn local(mtime: i64, size: i64) -> LocalItem {
|
||||||
|
LocalItem {
|
||||||
|
is_dir: false,
|
||||||
|
mtime,
|
||||||
|
size,
|
||||||
|
open_for_write: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote(mtime: i64, size: i64) -> RemoteItem {
|
||||||
|
RemoteItem {
|
||||||
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("l")),
|
||||||
|
is_dir: false,
|
||||||
|
mtime,
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline(
|
||||||
|
local_mtime: i64,
|
||||||
|
local_size: i64,
|
||||||
|
remote_mtime: i64,
|
||||||
|
remote_size: i64,
|
||||||
|
) -> StoredSyncEntry {
|
||||||
|
StoredSyncEntry {
|
||||||
|
rel_path: "file".into(),
|
||||||
|
remote_uid: Some("v~l".into()),
|
||||||
|
local_mtime,
|
||||||
|
local_size,
|
||||||
|
remote_rev: Some(remote_mtime.to_string()),
|
||||||
|
remote_hash: Some(remote_size.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_plan_covers_every_presence_and_change_shape() {
|
||||||
|
let same_local = local(10, 20);
|
||||||
|
let changed_local = local(11, 20);
|
||||||
|
let same_remote = remote(30, 40);
|
||||||
|
let changed_remote = remote(31, 40);
|
||||||
|
let base = baseline(10, 20, 30, 40);
|
||||||
|
let cases = [
|
||||||
|
(None, None, None, FilePlan::ForgetBaseline),
|
||||||
|
(None, None, Some(&base), FilePlan::ForgetBaseline),
|
||||||
|
(Some(&same_local), None, None, FilePlan::UploadNew),
|
||||||
|
(Some(&same_local), None, Some(&base), FilePlan::DeleteLocal),
|
||||||
|
(Some(&changed_local), None, Some(&base), FilePlan::UploadNew),
|
||||||
|
(None, Some(&same_remote), None, FilePlan::Download),
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(&same_remote),
|
||||||
|
Some(&base),
|
||||||
|
FilePlan::DeleteRemote,
|
||||||
|
),
|
||||||
|
(None, Some(&changed_remote), Some(&base), FilePlan::Download),
|
||||||
|
(
|
||||||
|
Some(&same_local),
|
||||||
|
Some(&same_remote),
|
||||||
|
None,
|
||||||
|
FilePlan::Conflict,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(&same_local),
|
||||||
|
Some(&same_remote),
|
||||||
|
Some(&base),
|
||||||
|
FilePlan::Unchanged,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(&changed_local),
|
||||||
|
Some(&same_remote),
|
||||||
|
Some(&base),
|
||||||
|
FilePlan::UploadRevision,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(&same_local),
|
||||||
|
Some(&changed_remote),
|
||||||
|
Some(&base),
|
||||||
|
FilePlan::Download,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(&changed_local),
|
||||||
|
Some(&changed_remote),
|
||||||
|
Some(&base),
|
||||||
|
FilePlan::Conflict,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (local, remote, baseline, expected) in cases {
|
||||||
|
assert_eq!(plan_file(local, remote, baseline), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_open_writer_overrides_every_other_file_decision() {
|
||||||
|
let mut local = local(11, 20);
|
||||||
|
local.open_for_write = true;
|
||||||
|
let remote = remote(31, 40);
|
||||||
|
let base = baseline(10, 20, 30, 40);
|
||||||
|
|
||||||
|
for (remote, baseline) in [
|
||||||
|
(None, None),
|
||||||
|
(None, Some(&base)),
|
||||||
|
(Some(&remote), None),
|
||||||
|
(Some(&remote), Some(&base)),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&local), remote, baseline),
|
||||||
|
FilePlan::Deferred
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_remote_signature_is_treated_as_changed() {
|
||||||
|
let local = local(10, 20);
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
base.remote_hash = None;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&local), Some(&remote), Some(&base)),
|
||||||
|
FilePlan::Download
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user