mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
399 lines
18 KiB
Rust
399 lines
18 KiB
Rust
//! Remote-event invalidation and local-index background workers.
|
|
|
|
use super::*;
|
|
|
|
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
|
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
|
/// which event carried them.
|
|
///
|
|
/// Returns whether this mount actually held the node, so the caller can evict
|
|
/// its content blob once if *any* mount did.
|
|
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
|
// Capture the inode before `forget` clears the uid mapping.
|
|
let child = st.by_uid.get(uid).copied();
|
|
let Some((parent, name)) = st.forget(uid) else {
|
|
return false;
|
|
};
|
|
if let Some(notifier) = notifier {
|
|
match child {
|
|
Some(child) => {
|
|
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
|
}
|
|
None => {
|
|
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Apply one remote event to the local cache and notify the kernel so it drops
|
|
/// any stale cached metadata/data for the affected inodes.
|
|
///
|
|
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
|
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
|
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
|
///
|
|
/// Applied to **every** mounted inode space, not just the primary one. This task
|
|
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
|
|
/// not a mount — so a file trashed from another device has to be withdrawn from
|
|
/// whichever of our sessions is showing it, which for anything under a sync
|
|
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
|
|
/// the primary state alone left forks serving a deleted file indefinitely, the
|
|
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
|
/// mount must be notified through its **own** channel; that pairing is what
|
|
/// [`Core::for_each_mount`] exists to preserve.
|
|
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
|
|
match event {
|
|
DriveEvent::NodeUpdated {
|
|
node_uid,
|
|
parent_node_uid,
|
|
is_trashed,
|
|
..
|
|
} => {
|
|
// A node we owe an upload for is *ahead* of the remote, not behind
|
|
// it: this event is almost always the echo of our own empty-file
|
|
// create, and re-fetching would replace the size and mtime of the
|
|
// write we just accepted with the stale revision's — making a file
|
|
// that was copied in seconds ago read as empty until its upload
|
|
// lands (offline.md Phase 3).
|
|
//
|
|
// A write that has already drained is the same story one step later:
|
|
// the drain brought the tree level with the revision it sealed, so
|
|
// the feed's report of that revision has nothing to add, and acting
|
|
// on it evicts the very bytes we uploaded. Claimed unconditionally,
|
|
// even for a trash, so an echo we no longer care about does not
|
|
// linger to suppress a later foreign change.
|
|
let echo = core.take_self_change(node_uid);
|
|
let ours = !*is_trashed && (echo || core.pending.lock().contains_key(node_uid));
|
|
if ours {
|
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
|
}
|
|
let mut had_node = false;
|
|
core.for_each_mount(|st, notifier| {
|
|
if *is_trashed {
|
|
// Trashing makes a node vanish from its parent listing.
|
|
had_node |= forget_and_notify(st, notifier, node_uid);
|
|
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
|
// Known node changed: drop its cached attrs/data (and
|
|
// listing if it is a directory) so the next access
|
|
// re-fetches. Its content blob may now be stale too.
|
|
had_node = true;
|
|
st.invalidate_listing(ino);
|
|
if let Some(notifier) = notifier {
|
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
}
|
|
}
|
|
});
|
|
// Once, not once per mount: the content cache is per-daemon.
|
|
if had_node {
|
|
core.cache.evict(node_uid);
|
|
}
|
|
// A create, rename or move-in shows up as a change to the parent
|
|
// listing too, which is recorded for the end of the batch rather
|
|
// than acted on here — see [`DirtyParents`].
|
|
if let Some(parent_uid) = parent_node_uid {
|
|
match ours {
|
|
true => dirty
|
|
.ours
|
|
.entry(parent_uid.clone())
|
|
.or_default()
|
|
.insert(node_uid.clone()),
|
|
false => dirty.foreign.insert(parent_uid.clone()),
|
|
};
|
|
}
|
|
}
|
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
|
core.cache.evict(node_uid);
|
|
core.for_each_mount(|st, notifier| {
|
|
forget_and_notify(st, notifier, node_uid);
|
|
});
|
|
}
|
|
// Continuity or scope was lost: our cached listings may be arbitrarily
|
|
// stale, so drop every listing and tell the kernel to forget all
|
|
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
|
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
|
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
|
core.for_each_mount(|st, notifier| {
|
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
|
for &ino in &dirs {
|
|
st.invalidate_listing(ino);
|
|
if let Some(notifier) = notifier {
|
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// No substantive local change; the cursor advance is handled by the
|
|
// caller persisting the event id.
|
|
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
|
}
|
|
}
|
|
|
|
/// The folders one batch of events changed, collected so the listings are
|
|
/// dropped once at the end of the batch rather than once per event.
|
|
///
|
|
/// Both halves of that matter. A burst of uploads produces one event per file
|
|
/// all naming the *same* folder, and dropping the listing per event made every
|
|
/// interleaved lookup re-enumerate that folder from the API — quadratic in its
|
|
/// size, and slow enough to stall a busy mount. Splitting foreign changes from
|
|
/// our own then removes the re-enumeration entirely for the common case: a file
|
|
/// this daemon created is already in the tree under the right parent, so there
|
|
/// is nothing for a re-enumeration to discover.
|
|
///
|
|
/// Neither is a weakening. The state after the batch is what it always was.
|
|
#[derive(Default)]
|
|
struct DirtyParents {
|
|
/// Folders changed by someone else. Their listings must go: an event names
|
|
/// no name, so a rename inside a folder is indistinguishable from a write to
|
|
/// a file in it, and only re-enumerating can tell us which happened.
|
|
foreign: HashSet<NodeUid>,
|
|
/// Folders changed by this daemon, and the children the changes were about.
|
|
/// Checked per mount — a mount that already lists every one of those
|
|
/// children under that parent keeps its listing.
|
|
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
|
}
|
|
|
|
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
|
/// `child_uid`, so a change we made to that child leaves nothing to re-read.
|
|
///
|
|
/// "Nothing cached" counts as current: an absent listing is re-enumerated on the
|
|
/// next `readdir` regardless, so there is nothing to drop.
|
|
fn listing_accounts_for(st: &State, parent_uid: &NodeUid, child_uid: &NodeUid) -> bool {
|
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
|
return true;
|
|
};
|
|
let Some(children) = st.children.get(&parent) else {
|
|
return true;
|
|
};
|
|
let Some(&child) = st.by_uid.get(child_uid) else {
|
|
return false;
|
|
};
|
|
children.contains(&child) && st.entries.get(&child).is_some_and(|e| e.parent == parent)
|
|
}
|
|
|
|
/// Drop the cached listing of every folder a batch of events changed, in every
|
|
/// mount that holds one, so the next `readdir`/`lookup` re-enumerates it.
|
|
///
|
|
/// Runs strictly after the whole batch is applied: doing it per event would
|
|
/// leave a window where a concurrent `readdir` re-populates a listing that a
|
|
/// later event in the same batch then has to drop again.
|
|
fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
|
return;
|
|
}
|
|
core.for_each_mount(|st, notifier| {
|
|
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
|
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
|
return;
|
|
};
|
|
st.invalidate_listing(parent);
|
|
if let Some(notifier) = notifier {
|
|
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
|
}
|
|
};
|
|
for parent_uid in &dirty.foreign {
|
|
drop_listing(st, parent_uid);
|
|
}
|
|
for (parent_uid, children) in &dirty.ours {
|
|
if dirty.foreign.contains(parent_uid) {
|
|
continue;
|
|
}
|
|
// One child this mount has not placed is enough: the listing it is
|
|
// serving is missing something the user just made.
|
|
if children
|
|
.iter()
|
|
.all(|child| listing_accounts_for(st, parent_uid, child))
|
|
{
|
|
continue;
|
|
}
|
|
drop_listing(st, parent_uid);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Poll the remote event cursor forever, applying each batch to the shared
|
|
/// state. Resumes from the cursor persisted in the DB so changes made while
|
|
/// unmounted are applied; only a first-ever mount seeds from the server head.
|
|
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
|
|
/// only on fatal error.
|
|
///
|
|
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
|
/// to be applied to every mounted inode space, and the registry that enumerates
|
|
/// them lives on the `Core` (see [`apply_event`]).
|
|
pub(super) async fn run_event_sync(
|
|
client: ProtonDriveClient,
|
|
scope: DriveEventScopeId,
|
|
core: Core,
|
|
) {
|
|
let db = core.db.clone();
|
|
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
|
// Resume: pick up exactly where the last run left off.
|
|
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
|
|
// First mount: a `None` cursor yields a single `CursorAdvanced` at the
|
|
// server head; persist it so the next restart resumes instead of
|
|
// reseeding (which would skip everything that changed offline).
|
|
// Seeding needs the network, and this task also runs on mounts that
|
|
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
|
// which used to disable live sync for the life of the daemon.
|
|
Ok(None) => {
|
|
let mut delay = ONLINE_PROBE_MIN;
|
|
loop {
|
|
match client.enumerate_events(&scope, None).await {
|
|
Ok(events) => {
|
|
let head = events.last().map(|e| e.id().clone());
|
|
let Some(c) = &head else {
|
|
break None;
|
|
};
|
|
if let Err(e) = db.set_event_cursor(c.as_str()) {
|
|
warn!(error = %e, ?delay, "persist seed cursor failed; retrying");
|
|
tokio::time::sleep(delay).await;
|
|
delay = (delay * 2).min(ONLINE_PROBE_MAX);
|
|
continue;
|
|
}
|
|
break Some(c.clone());
|
|
}
|
|
Err(e) => {
|
|
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
|
tokio::time::sleep(delay).await;
|
|
delay = (delay * 2).min(ONLINE_PROBE_MAX);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!(error = %e, "read persisted cursor failed; live sync disabled");
|
|
return;
|
|
}
|
|
};
|
|
info!(?cursor, "event sync started");
|
|
|
|
loop {
|
|
tokio::time::sleep(POLL_INTERVAL).await;
|
|
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
|
|
Ok(events) => events,
|
|
Err(e) => {
|
|
warn!(error = %e, "event poll failed; retrying after interval");
|
|
continue;
|
|
}
|
|
};
|
|
if events.is_empty() {
|
|
continue;
|
|
}
|
|
debug!(count = events.len(), "applying remote events");
|
|
// Folders whose listing this batch invalidates, applied once at the end
|
|
// rather than per event — see [`DirtyParents`]. Every `break` below falls
|
|
// through to that flush, so an interrupted batch still publishes what it
|
|
// did apply.
|
|
let mut dirty = DirtyParents::default();
|
|
for event in &events {
|
|
// Converge the SDK's own caches (folder keys, entity cache) on the
|
|
// server before applying the event to our tree. Without this, a node
|
|
// re-keyed/moved by another client keeps a stale key in the SDK for
|
|
// the life of the daemon (SDK plan #9). `apply_event` only touches
|
|
// our FUSE state, so nothing else does this.
|
|
if let Err(e) = client.invalidate_caches_for_event(event).await {
|
|
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
|
break;
|
|
}
|
|
apply_event(&core, event, &mut dirty);
|
|
let applied = event.id().clone();
|
|
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
|
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
|
break;
|
|
}
|
|
// Advance only after this exact event is durably acknowledged. If a
|
|
// later event fails, polling resumes from here and replays nothing
|
|
// that was not already made visible locally.
|
|
cursor = Some(applied);
|
|
}
|
|
flush_dirty_parents(&core, &dirty);
|
|
}
|
|
}
|
|
|
|
/// Keep the local-file index fresh for the launcher prompt's "This computer"
|
|
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
|
|
/// then sleeps; runs on its own thread for the life of the daemon.
|
|
///
|
|
/// The walk is the one part of the daemon that touches the wider filesystem, so
|
|
/// it is deliberately kept off every hot path: it never runs on a FUSE or
|
|
/// control-socket thread, and it excludes the mountpoint (walking it would fault
|
|
/// every remote node in through FUSE, defeating on-demand hydration).
|
|
pub(super) fn run_local_index(
|
|
db: Arc<Db>,
|
|
indexing: Arc<AtomicBool>,
|
|
transfers: Arc<TransferRegistry>,
|
|
mountpoint: PathBuf,
|
|
) {
|
|
loop {
|
|
let age = db.local_indexed_at().ok().flatten();
|
|
let stale =
|
|
age.is_none_or(|at| now_secs().saturating_sub(at) >= LOCAL_INDEX_TTL.as_secs() as i64);
|
|
if stale {
|
|
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
|
}
|
|
std::thread::sleep(LOCAL_INDEX_CHECK);
|
|
}
|
|
}
|
|
|
|
/// Walk `$HOME` once and replace the local-file index with what it finds.
|
|
/// Batches stream straight into SQLite, so peak memory is one batch — not the
|
|
/// whole home directory.
|
|
///
|
|
/// Reports itself as a job: the first scan after a fresh install walks the whole
|
|
/// home directory, and `indexing` alone only tells the launcher prompt to say
|
|
/// "still indexing" — nothing else showed that the daemon was busy.
|
|
fn scan_local_once(
|
|
db: &Db,
|
|
indexing: &AtomicBool,
|
|
transfers: &Arc<TransferRegistry>,
|
|
mountpoint: &Path,
|
|
) {
|
|
let dirs = match AppDirs::new() {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
warn!(error = %e, "local index: cannot resolve app dirs");
|
|
return;
|
|
}
|
|
};
|
|
let Some(home) = dirs.home_dir() else {
|
|
warn!("local index: cannot resolve home directory");
|
|
return;
|
|
};
|
|
let generation = match db.local_begin_scan() {
|
|
Ok(g) => g,
|
|
Err(e) => {
|
|
warn!(error = %e, "local index: cannot open scan generation");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
|
indexing.store(true, Ordering::Relaxed);
|
|
let started = Instant::now();
|
|
|
|
// The walk has no idea how many files it will find, so the job counts what it
|
|
// has seen and stays indeterminate.
|
|
let job = transfers.begin_job("Indexing this computer");
|
|
job.detail("Scanning your files");
|
|
let walked = localindex::scan(&[home], &excludes, |batch| {
|
|
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
|
warn!(error = %e, "local index: batch write failed");
|
|
}
|
|
});
|
|
|
|
// Prune what this scan did not see and rebuild the FTS index over the rest,
|
|
// even if some batches failed — a partial index still beats none.
|
|
job.detail("Building the search index");
|
|
match db.local_finish_scan(generation, now_secs()) {
|
|
Ok(indexed) => info!(
|
|
walked,
|
|
indexed,
|
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
|
"local index rebuilt"
|
|
),
|
|
Err(e) => warn!(error = %e, "local index: finish failed"),
|
|
}
|
|
indexing.store(false, Ordering::Relaxed);
|
|
}
|