mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
66 comments cited `bugs.md B<n>`, the ledger's old name; they now say `docs/BUGS.md B<n>`, and every cited number has an entry. About 90 more cited offline.md, devices.md, features.md, mount-architecture.md, plan.md and improvements.md, planning notes that were never in the repository. Those citations are removed; the comments keep their text.
503 lines
21 KiB
Rust
503 lines
21 KiB
Rust
//! Background conflict sweep.
|
|
//!
|
|
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
|
|
//! engine cut one when they cannot prove a queued write is safe to apply, so the
|
|
//! user never loses bytes. Most of those copies turn out to be byte-identical to
|
|
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
|
|
//! first-sync of an already-identical file. This sweep reconciles them after the
|
|
//! fact:
|
|
//!
|
|
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
|
|
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
|
|
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
|
|
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
|
|
//! user can decide which version wins.
|
|
//!
|
|
//! Identity is proved without downloading anything: the SHA-1 comes from the
|
|
//! revision's decrypted extended attributes, which enumeration already reads. A
|
|
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
|
|
//! never removes a file it cannot prove is a duplicate.
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use pdfs_core::batch;
|
|
use pdfs_core::config::SweepMode;
|
|
use pdfs_core::control::ActivityKind;
|
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use super::{Core, node_content_sha1, node_revision_id, node_size};
|
|
|
|
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
|
|
/// the sweep off the hot path while still cleaning up within a few minutes.
|
|
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
|
|
|
/// Delay before the first sweep, so it does not compete with the burst of work a
|
|
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
|
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
|
|
|
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
|
|
/// returning the original file name it was a copy of. `None` when `name` is not a
|
|
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
|
|
/// formats, so the two halves of the product agree on what a conflict is.
|
|
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
|
const MARKER: &str = " (sync-conflict ";
|
|
let start = name.find(MARKER)?;
|
|
let after = start + MARKER.len();
|
|
let close_rel = name[after..].find(')')?;
|
|
let close = after + close_rel;
|
|
let inner = &name[after..close];
|
|
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
|
// file that merely happens to contain the phrase is not mistaken for a copy.
|
|
let mut parts = inner.splitn(2, '-');
|
|
let stamp = parts.next().unwrap_or_default();
|
|
let suffix = parts.next();
|
|
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
|
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
|
|
return None;
|
|
}
|
|
let mut base = String::with_capacity(start + name.len() - close);
|
|
base.push_str(&name[..start]);
|
|
base.push_str(&name[close + 1..]);
|
|
Some(base)
|
|
}
|
|
|
|
impl Core {
|
|
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
|
/// docs for the identical/divergent policy.
|
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
|
// Both waits are interruptible, so teardown does not have to sit out a
|
|
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
|
return;
|
|
}
|
|
loop {
|
|
self.sweep_conflicts_once();
|
|
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One sweep pass over every node the daemon currently knows.
|
|
fn sweep_conflicts_once(&self) {
|
|
let stored = match self.db.load_all() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
warn!(error = ?e, "conflict sweep: load nodes failed");
|
|
return;
|
|
}
|
|
};
|
|
let nodes: Vec<_> = stored
|
|
.into_iter()
|
|
.map(|stored| stored.node)
|
|
.filter(|node| self.is_own_or_virtual(&node.uid))
|
|
.collect();
|
|
|
|
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
|
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
|
for node in &nodes {
|
|
if node.trashed || node.is_folder() {
|
|
continue;
|
|
}
|
|
let parent = node
|
|
.parent_uid
|
|
.as_ref()
|
|
.map(|u| u.to_string())
|
|
.unwrap_or_default();
|
|
by_parent_name.insert((parent, node.name.as_str()), node);
|
|
}
|
|
|
|
let roots = self.search_roots();
|
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
|
for node in &nodes {
|
|
if node.trashed || node.is_folder() {
|
|
continue;
|
|
}
|
|
let Some(base) = conflict_base_name(&node.name) else {
|
|
continue;
|
|
};
|
|
let parent = node
|
|
.parent_uid
|
|
.as_ref()
|
|
.map(|u| u.to_string())
|
|
.unwrap_or_default();
|
|
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
|
|
|
|
match decide(node, sibling, &base, self.sweep_mode) {
|
|
SweepAction::Remove => {
|
|
// Only while online — the remote trash is the point. Offline,
|
|
// it waits for a later pass.
|
|
if online {
|
|
self.remove_conflict_copy(node, &base);
|
|
}
|
|
}
|
|
// Only flag what the conflict listing can show. Anything else
|
|
// would sit in the feed as "resolved" with no way to act on
|
|
// it (`docs/BUGS.md` B99).
|
|
SweepAction::Flag(detail) => {
|
|
if self.conflict_path(&roots, &node.uid).is_some() {
|
|
self.flag_conflict(&node.uid, &node.name, detail);
|
|
} else {
|
|
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Trash a conflict copy proven identical to its live sibling, and unhook it
|
|
/// locally — the same sequence the interactive unlink path uses after a trash.
|
|
///
|
|
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
|
|
/// enumerated every node up front and has been doing network I/O since. Before
|
|
/// touching anything remote this re-reads the node and refuses to act unless
|
|
/// it is *still* the same revision, still untrashed, still unwritten and still
|
|
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
|
/// or its staged blob: a write that arrives after that check is left for the
|
|
/// drain to resolve against the now-trashed node.
|
|
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
|
let uid = &snapshot.uid;
|
|
let name = snapshot.name.as_str();
|
|
if !self.duplicate_still_removable(snapshot) {
|
|
return;
|
|
}
|
|
match self
|
|
.rt
|
|
.block_on(self.drive.trash_nodes(std::slice::from_ref(uid)))
|
|
.and_then(batch::into_unit)
|
|
{
|
|
Ok(()) => {}
|
|
Err(e) if super::is_gone(&e) => {}
|
|
Err(e) => {
|
|
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
|
|
return;
|
|
}
|
|
}
|
|
// Leaving a conflict copy hooked up in the tree keeps a node we have
|
|
// just trashed remotely visible and readable in whichever mount shows
|
|
// it (`docs/BUGS.md` B74).
|
|
self.for_each_state(|st| {
|
|
st.forget_or_unlink(uid);
|
|
});
|
|
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
|
// proved the queue empty before the trash above, but that proof is stale
|
|
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
|
// that window stages bytes, and discarding here would unlink the staged
|
|
// blob holding the only copy of them. Leaving the op queued is the
|
|
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
|
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
|
self.cache.evict(uid);
|
|
self.evict_reader(uid);
|
|
if let Err(e) = self.db.delete_node(uid) {
|
|
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
|
|
}
|
|
self.invalidate_trash();
|
|
self.conflict_notified.lock().remove(uid);
|
|
self.log_activity(
|
|
ActivityKind::Trash,
|
|
name,
|
|
format!("auto-removed duplicate of {base}"),
|
|
true,
|
|
);
|
|
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
|
|
}
|
|
|
|
/// Re-check, immediately before the destructive call, everything the pass
|
|
/// decided from its stale snapshot. Any doubt means "leave it alone": a
|
|
/// conflict copy that survives to the next pass costs nothing, a wrongly
|
|
/// removed one costs the user data.
|
|
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
|
|
let uid = &snapshot.uid;
|
|
let name = snapshot.name.as_str();
|
|
|
|
// A queued op means bytes are still owed an upload; trashing the node
|
|
// under them turns an ordinary upload into a conflict fork.
|
|
match self.db.has_any_op(&uid.to_string()) {
|
|
Ok(false) => {}
|
|
Ok(true) => {
|
|
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
|
|
return false;
|
|
}
|
|
// Cannot prove the queue is empty — assume it is not.
|
|
Err(e) => {
|
|
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Someone is writing to it, or holds it open. Trashing now would pull the
|
|
// node out from under a live handle.
|
|
if self.is_busy(uid) {
|
|
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
|
|
return false;
|
|
}
|
|
|
|
// Finally, the node itself must not have moved on since the snapshot.
|
|
match self.db.node_by_uid(&uid.to_string()) {
|
|
Ok(Some(fresh)) => {
|
|
if fresh.trashed {
|
|
return false;
|
|
}
|
|
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
|
|
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
|
|
return false;
|
|
}
|
|
if node_revision_id(&fresh) != node_revision_id(snapshot)
|
|
|| node_size(&fresh) != node_size(snapshot)
|
|
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
|
|
{
|
|
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
// Already gone locally, or unreadable — either way, not ours to remove.
|
|
Ok(None) => false,
|
|
Err(e) => {
|
|
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a node has a write in flight or a file handle open against it.
|
|
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
|
/// writer/holder check.
|
|
/// Asked across **every** mount. The sweep runs on the primary Core but
|
|
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
|
|
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
|
/// answered "idle" for every such file and let the sweep delete one with a
|
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
|
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
|
let mut busy = false;
|
|
self.for_each_state(|st| {
|
|
let Some(&ino) = st.by_uid.get(uid) else {
|
|
return;
|
|
};
|
|
busy |=
|
|
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
|
|
});
|
|
busy
|
|
}
|
|
|
|
/// Surface a conflict copy that needs the user's attention, once. The
|
|
/// in-memory set spares the database each pass; the activity log itself is
|
|
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
|
|
/// B99). A copy's name carries its own timestamp, so the name identifies it.
|
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
|
return;
|
|
}
|
|
match self.db.activity_has(ActivityKind::Conflict, name) {
|
|
Ok(false) => {}
|
|
Ok(true) => return,
|
|
// Cannot tell: a repeat entry is harmless, a missing one hides a
|
|
// conflict.
|
|
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
|
|
}
|
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
|
}
|
|
}
|
|
|
|
/// What the sweep concluded about one `(sync-conflict …)` copy.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum SweepAction {
|
|
/// Provably redundant: trash it, subject to the runtime interlocks in
|
|
/// [`Core::duplicate_still_removable`].
|
|
Remove,
|
|
/// Only the user can resolve this one; surface it with this detail.
|
|
Flag(String),
|
|
}
|
|
|
|
/// Decide what to do about conflict copy `node` given its live sibling (if any)
|
|
/// and the configured `mode`.
|
|
///
|
|
/// Pure, so the policy that governs deletion is testable without a mount, a
|
|
/// runtime, or an account — the same split used for `revision_changed` and
|
|
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
|
|
fn decide(
|
|
node: &proton_drive_rs::Node,
|
|
sibling: Option<&proton_drive_rs::Node>,
|
|
base: &str,
|
|
mode: SweepMode,
|
|
) -> SweepAction {
|
|
match sibling {
|
|
Some(sib) if is_identical(node, sib) => {
|
|
if mode.is_enforcing() {
|
|
SweepAction::Remove
|
|
} else {
|
|
// Report-only: say what would happen, remove nothing. The point is
|
|
// to accumulate field evidence that the identity check is right
|
|
// before it is trusted with deletion (docs/BUGS.md B71).
|
|
SweepAction::Flag(format!(
|
|
"identical to {base}; would be removed (sweep is report-only)"
|
|
))
|
|
}
|
|
}
|
|
Some(_) => SweepAction::Flag(format!("differs from {base}")),
|
|
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
|
|
}
|
|
}
|
|
|
|
/// Whether two file nodes provably hold the same bytes: equal size and equal,
|
|
/// present content SHA-1. A missing digest on either side is not proof, so the
|
|
/// sweep treats it as divergent rather than risk removing a real difference.
|
|
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
|
|
if node_size(a) != node_size(b) {
|
|
return false;
|
|
}
|
|
match (node_content_sha1(a), node_content_sha1(b)) {
|
|
(Some(x), Some(y)) => x == y,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use proton_drive_rs::NodeKind;
|
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
|
|
|
/// A file node with the two fields the sweep's identity check reads.
|
|
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
|
|
proton_drive_rs::Node {
|
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
|
|
parent_uid: None,
|
|
kind: NodeKind::File {
|
|
media_type: "text/plain".into(),
|
|
total_size_on_storage: size,
|
|
active_revision_state: None,
|
|
active_revision_id: Some("rev".into()),
|
|
claimed_size: Some(size),
|
|
claimed_modification_time: None,
|
|
content_sha1: sha1.map(String::from),
|
|
},
|
|
name: name.into(),
|
|
creation_time: 0,
|
|
modification_time: 0,
|
|
trashed: false,
|
|
is_shared: false,
|
|
is_shared_publicly: false,
|
|
signature_email: None,
|
|
membership: None,
|
|
photo: None,
|
|
album: None,
|
|
verification: Default::default(),
|
|
direct_role: None,
|
|
share_id: None,
|
|
}
|
|
}
|
|
|
|
/// The only input shape that may ever produce a removal: same size, same
|
|
/// present digest, live sibling, enforcing mode.
|
|
#[test]
|
|
fn removes_only_a_provably_identical_copy_when_enforcing() {
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("aaa"));
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Remove
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_only_never_removes_but_still_says_what_it_would_do() {
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("aaa"));
|
|
// Same inputs as the removal case above; only the mode differs.
|
|
let SweepAction::Flag(detail) = decide(©, Some(&live), "f.txt", SweepMode::Report)
|
|
else {
|
|
panic!("report mode must never remove");
|
|
};
|
|
assert!(detail.contains("would be removed"), "{detail}");
|
|
assert!(detail.contains("report-only"), "{detail}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_differing_size_is_a_real_conflict() {
|
|
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("aaa"));
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("differs from f.txt".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_digest_is_never_proof_of_identity() {
|
|
// Equal sizes are not enough: two different 100-byte files are common.
|
|
// A copy the sweep cannot fingerprint must survive, in either direction.
|
|
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
|
|
let copy = file("f (sync-conflict 1).txt", 100, a);
|
|
let live = file("f.txt", 100, b);
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("differs from f.txt".into()),
|
|
"{a:?} vs {b:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
let live = file("f.txt", 100, Some("bbb"));
|
|
assert_eq!(
|
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("differs from f.txt".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_orphaned_copy_is_surfaced_not_removed() {
|
|
// No live original left to reconcile against — the copy may be the only
|
|
// surviving version of the file, so it is never the sweep's to remove.
|
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
|
assert_eq!(
|
|
decide(©, None, "f.txt", SweepMode::Enforce),
|
|
SweepAction::Flag("no original f.txt to reconcile against".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_needs_both_size_and_digest_to_match() {
|
|
let a = file("a", 100, Some("aaa"));
|
|
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
|
|
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
|
|
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
|
|
assert!(!is_identical(&a, &file("b", 100, None)));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_conflict_names_and_leaves_others_alone() {
|
|
assert_eq!(
|
|
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
|
|
Some("test_export.xml")
|
|
);
|
|
assert_eq!(
|
|
conflict_base_name("README (sync-conflict 42)").as_deref(),
|
|
Some("README")
|
|
);
|
|
// The sync engine's de-duplicated suffix form.
|
|
assert_eq!(
|
|
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
|
Some("notes.txt")
|
|
);
|
|
// Not a conflict copy.
|
|
assert_eq!(conflict_base_name("report.xml"), None);
|
|
// Looks close but the token is not a stamp.
|
|
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dotted_stem_keeps_its_inner_dots() {
|
|
assert_eq!(
|
|
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
|
|
Some("archive.tar.gz")
|
|
);
|
|
}
|
|
}
|