Files
proton-drive-linux/crates/pdfs-fuse/src/keeper.rs
T
nvrl 14a663cee9 fix(fuse): keep pinned files on disk through remote changes
A remote change to a pinned file called `ContentCache::evict`, which does
not check pins, and only the pin request itself ever downloaded anything.
An edit from another device, or even a rename, left the file listed as kept
offline with no copy, so the next read downloaded it or failed without a
network. A file added to a pinned folder elsewhere was never downloaded,
and a folder pin whose downloads failed while offline never retried them.

A remote change no longer evicts a pinned file's copy. The copy is checked
against the revision's (mtime, size), so a newer revision does not read
it, and a rename keeps the mtime (B123), so the copy stays valid. A new
pdfs-pin-keeper thread checks pins again and downloads files that are
missing or stale:

- the pins a batch of remote events changed, after the batch's listings
  are dropped, including a pinned folder that gets a new file;
- every pin at startup, when the link returns, and after the event feed
  loses continuity;
- a file whose version is restored.

Failed downloads retry with a backoff of 30 s to 30 min. Pinning and the
keeper settle a provisional size first: a copy stored under the
ciphertext size never matched the plaintext it holds.

Fixes B282.
2026-11-14 00:20:06 +01:00

272 lines
9.5 KiB
Rust

//! Keeping pinned files on this computer after they were pinned.
//!
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
//! change from another device deleted the cached copy, a file added to a
//! pinned folder elsewhere was never fetched, and a download that failed while
//! the link was down stayed failed. Each of those left a file listed as kept
//! offline that the next read downloaded, and that could not be read at all
//! without a network (`docs/BUGS.md` B282).
//!
//! The keeper is one thread that checks pins again and downloads what is
//! missing or stale: every pin when the daemon starts and when the link comes
//! back, and the pins a batch of remote events touched once the batch is
//! applied. A remote change no longer deletes a pinned file's copy. The copy
//! is stored under the revision's `(mtime, size)`, so a read of a newer
//! revision does not take it, and the keeper replaces it once the new one is
//! downloaded.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, info, warn};
use super::{Core, is_local_uid, node_size, parse_uid};
/// How often the keeper looks for work.
const KEEPER_TICK: Duration = Duration::from_secs(1);
/// The first wait before a check that could not download everything is tried
/// again. It doubles on every failure, up to [`RETRY_MAX`].
const RETRY_FIRST: Duration = Duration::from_secs(30);
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
/// What the keeper owes a check.
#[derive(Debug, Default)]
pub(crate) struct PinWork {
/// Every pin.
all: bool,
/// The pinned files and folders named here. A folder is checked whole.
nodes: HashSet<NodeUid>,
}
impl PinWork {
fn is_empty(&self) -> bool {
!self.all && self.nodes.is_empty()
}
fn merge(&mut self, other: PinWork) {
self.all |= other.all;
self.nodes.extend(other.nodes);
}
}
/// A file a pin keeps, as the tree knows it now.
pub(crate) struct KeptFile {
pub(crate) uid: NodeUid,
pub(crate) name: String,
pub(crate) mtime: i64,
pub(crate) size: u64,
}
impl Core {
/// Have the keeper check every pin.
pub(crate) fn recheck_all_pins(&self) {
self.pin_work.lock().all = true;
}
/// Have the keeper check the pinned file or folder `uid`.
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
self.pin_work.lock().nodes.insert(uid.clone());
}
/// The keeper's loop, until the daemon stops. Starts with a check of every
/// pin, which catches up on whatever changed while the daemon was down.
///
/// Work that failed waits out a backoff of its own. New work does not wait
/// for it, so a file that keeps failing does not hold up the next change.
pub(crate) fn run_pin_keeper(&self) {
self.recheck_all_pins();
let mut backlog = PinWork::default();
let mut backlog_due = Instant::now();
let mut retry = RETRY_FIRST;
while self.shutdown.sleep(KEEPER_TICK) {
if !self.is_online() {
continue;
}
let mut work = std::mem::take(&mut *self.pin_work.lock());
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
if retrying {
work.merge(std::mem::take(&mut backlog));
}
if work.is_empty() {
continue;
}
let failed = self.keep_pins(&work);
if failed.is_empty() {
if retrying {
retry = RETRY_FIRST;
}
continue;
}
backlog.merge(failed);
backlog_due = Instant::now() + retry;
retry = (retry * 2).min(RETRY_MAX);
}
}
/// Download every file `work` covers whose cached copy is missing or
/// stale. Returns what could not be checked or downloaded.
fn keep_pins(&self, work: &PinWork) -> PinWork {
let mut failed = PinWork::default();
let roots: Vec<NodeUid> = if work.all {
self.cache
.list_pins()
.iter()
.filter_map(|pin| parse_uid(&pin.uid))
.chain(work.nodes.iter().cloned())
.collect()
} else {
work.nodes.iter().cloned().collect()
};
let mut seen: HashSet<u64> = HashSet::new();
let mut files: Vec<u64> = Vec::new();
for uid in roots {
if self.shutdown.is_stopping() {
return failed;
}
if !self.cache.is_pinned(&uid) {
continue;
}
let found = {
let st = self.state();
st.by_uid.get(&uid).and_then(|&ino| {
let e = st.entries.get(&ino)?;
Some((ino, e.parent, e.node.is_folder()))
})
};
let Some((ino, parent, is_folder)) = found else {
debug!(%uid, "pin keeper: pinned node is not in the tree");
continue;
};
if is_folder {
match self.pin_subtree_files(ino) {
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
Err(e) => {
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
failed.nodes.insert(uid);
}
}
continue;
}
// An event about this file dropped its parent's listing. Listing
// it again brings the file's entry up to the revision Drive has
// now, which is what the cached copy is checked against.
if !work.all
&& parent != ino
&& let Err(e) = self.ensure_children(parent)
{
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
failed.nodes.insert(uid);
continue;
}
if seen.insert(ino) {
files.push(ino);
}
}
let stale: Vec<KeptFile> = files
.into_iter()
.filter_map(|ino| self.kept_file(ino))
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
.collect();
if stale.is_empty() {
return failed;
}
let job = self.transfers.begin_job("Updating files kept offline");
job.set_total(stale.len() as u64);
let (mut fetched, mut failures) = (0usize, 0usize);
for file in stale {
if self.shutdown.is_stopping() {
break;
}
// Unpinned while the check ran.
if !self.cache.is_pinned(&file.uid) {
job.step();
continue;
}
job.detail(file.name.clone());
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
Ok(()) => fetched += 1,
Err(e) => {
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
failed.nodes.insert(file.uid);
failures += 1;
}
}
job.step();
}
info!(
fetched,
failed = failures,
"pin keeper: brought files kept offline up to date"
);
failed
}
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
/// daemon still owes an upload for, or a file whose real size cannot be
/// read.
///
/// A queued write is ahead of Drive. Downloading Drive's revision would
/// cache what the write replaces, and the drain keeps the uploaded bytes
/// of a pinned file once they land.
///
/// A listing carries the ciphertext size until its size upgrade lands
/// (B12). Stored under that size, the copy never matched the plaintext it
/// holds, so every read downloaded the file again and every check
/// downloaded it once more.
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
let (uid, name, mtime, size, provisional) = {
let st = self.state();
let e = st.entries.get(&ino)?;
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
return None;
};
(
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
claimed_size.is_none(),
)
};
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
return None;
}
let size = match provisional {
true => self.resolve_provisional_size(ino, &uid)?,
false => size,
};
Some(KeptFile {
uid,
name,
mtime,
size,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_that_failed_keeps_every_node_and_the_full_check() {
let mut backlog = PinWork::default();
assert!(backlog.is_empty());
let uid = parse_uid("vol~a").unwrap();
backlog.merge(PinWork {
all: false,
nodes: HashSet::from([uid.clone()]),
});
backlog.merge(PinWork {
all: true,
nodes: HashSet::from([uid.clone()]),
});
assert!(backlog.all);
assert_eq!(backlog.nodes.len(), 1);
assert!(!backlog.is_empty());
}
}