mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
relocate() called forget(), dropping the node's by_uid mapping, so the re-enumeration assigned a fresh inode. The kernel had already carried the renamed dentry onto the old one and holds it for the entry TTL, so lookup/getattr/opendir resolved to an inode entries no longer held and returned ENOENT — while readdir walked children and reported the new inode. A renamed directory listed in its parent but could not be entered until the TTL expired. Rewrite the node in place and invalidate both parents' listings instead. Also stops the moved node's DB row being deleted and recreated on every move, which the B1 data-loss fix worked around
496 lines
20 KiB
Rust
496 lines
20 KiB
Rust
//! Inode bookkeeping: the maps that give a remote node a stable kernel inode,
|
|
//! the open write handles, and the interval set that tracks which bytes of a
|
|
//! staged write are authored locally.
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs::File;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use pdfs_core::cache::StagedWrite;
|
|
use pdfs_core::db::Db;
|
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
|
use proton_drive_rs::{Node, NodeKind};
|
|
use tracing::warn;
|
|
|
|
/// A node known to the filesystem, addressed by its kernel inode.
|
|
pub(crate) struct Entry {
|
|
pub(crate) uid: NodeUid,
|
|
pub(crate) parent: u64,
|
|
pub(crate) node: Node,
|
|
}
|
|
|
|
/// A set of non-overlapping `[start, end)` byte ranges, kept sorted and merged.
|
|
/// Tracks which bytes of a [`WriteHandle`]'s scratch file were authored locally
|
|
/// (vs. still living only in the remote base), so reads and the commit gap-fill
|
|
/// know which regions to pull from the network.
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct Intervals(pub(crate) Vec<(u64, u64)>);
|
|
|
|
impl Intervals {
|
|
/// Mark `[start, end)` as authored, coalescing with any touching ranges.
|
|
pub(crate) fn add(&mut self, start: u64, end: u64) {
|
|
if start >= end {
|
|
return;
|
|
}
|
|
self.0.push((start, end));
|
|
self.0.sort_by_key(|&(s, _)| s);
|
|
let mut merged: Vec<(u64, u64)> = Vec::with_capacity(self.0.len());
|
|
for &(s, e) in &self.0 {
|
|
match merged.last_mut() {
|
|
Some(last) if s <= last.1 => last.1 = last.1.max(e),
|
|
_ => merged.push((s, e)),
|
|
}
|
|
}
|
|
self.0 = merged;
|
|
}
|
|
|
|
/// Drop everything at or beyond `len` (a shrink/truncate).
|
|
pub(crate) fn clip(&mut self, len: u64) {
|
|
self.0.retain(|&(s, _)| s < len);
|
|
for iv in &mut self.0 {
|
|
iv.1 = iv.1.min(len);
|
|
}
|
|
}
|
|
|
|
/// Split `[start, end)` into contiguous `(s, e, authored)` segments, in
|
|
/// order. `authored == true` means the bytes live in the scratch file;
|
|
/// `false` means they must come from the remote base (or are a hole).
|
|
pub(crate) fn segments(&self, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
|
|
let mut out = Vec::new();
|
|
let mut pos = start;
|
|
for &(s, e) in &self.0 {
|
|
if e <= start {
|
|
continue;
|
|
}
|
|
if s >= end {
|
|
break;
|
|
}
|
|
let ws = s.max(start);
|
|
let we = e.min(end);
|
|
if pos < ws {
|
|
out.push((pos, ws, false));
|
|
}
|
|
out.push((ws, we, true));
|
|
pos = we;
|
|
}
|
|
if pos < end {
|
|
out.push((pos, end, false));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// State for a file opened for writing. Authored bytes are staged in an on-disk
|
|
/// scratch file (positional reads/writes) rather than RAM, so a multi-GiB write
|
|
/// never balloons the daemon. On flush/release the scratch file — gap-filled
|
|
/// from the remote base where untouched — is streamed up as one new revision,
|
|
/// since the SDK seals whole revisions rather than byte ranges.
|
|
pub(crate) struct WriteHandle {
|
|
pub(crate) ino: u64,
|
|
pub(crate) uid: NodeUid,
|
|
/// Disk-backed staging buffer. Shared (`Arc`) so reads can use it without
|
|
/// holding the state lock across I/O. Accessed positionally (`read_at`/
|
|
/// `write_at`), so a clone never disturbs another's file offset.
|
|
pub(crate) file: Arc<File>,
|
|
/// Scratch file path, removed on release.
|
|
pub(crate) path: PathBuf,
|
|
/// Byte ranges authored into `file`. Everything else in `[0, len)` is base.
|
|
pub(crate) written: Intervals,
|
|
/// Logical file size (may exceed authored bytes after a truncate-extend).
|
|
pub(crate) len: u64,
|
|
/// Size of the remote base at open, for serving untouched ranges.
|
|
pub(crate) base_size: u64,
|
|
/// Modification time of the remote base at open, validating its block cache.
|
|
pub(crate) base_mtime: i64,
|
|
/// Whether anything diverged from the remote and needs an upload.
|
|
pub(crate) dirty: bool,
|
|
}
|
|
|
|
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
|
///
|
|
/// The bytes live in the content cache's staging dir and the intent lives in the
|
|
/// `pending_op` table; this pairs them in memory so a read can be served without
|
|
/// a database round trip.
|
|
#[derive(Clone)]
|
|
pub(crate) struct PendingRevision {
|
|
/// Staged blob holding the written bytes.
|
|
pub(crate) path: PathBuf,
|
|
/// Which of those bytes are real, and what base the gaps refer to.
|
|
pub(crate) meta: StagedWrite,
|
|
}
|
|
|
|
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
|
/// `Filesystem` trait through `&self`.
|
|
pub(crate) struct State {
|
|
/// inode -> node metadata.
|
|
pub(crate) entries: HashMap<u64, Entry>,
|
|
/// Dedupe inodes by node uid so a node keeps a stable inode across lookups.
|
|
pub(crate) by_uid: HashMap<NodeUid, u64>,
|
|
/// Cached directory listings: parent inode -> child inodes. Presence of a
|
|
/// key means the directory has been enumerated.
|
|
pub(crate) children: HashMap<u64, Vec<u64>>,
|
|
pub(crate) next_ino: u64,
|
|
/// Open write handles keyed by file handle id. Read-only opens use fh 0 and
|
|
/// have no entry here.
|
|
pub(crate) handles: HashMap<u64, WriteHandle>,
|
|
pub(crate) next_fh: u64,
|
|
/// Unified SQLite metadata cache. Every map mutation below writes through to
|
|
/// it inside the `State` lock so the DB stays the authoritative copy across
|
|
/// restarts (see plan.md P1).
|
|
pub(crate) db: Arc<Db>,
|
|
}
|
|
|
|
impl State {
|
|
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
|
if let Err(e) = self.db.upsert_node(&node) {
|
|
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
|
|
}
|
|
self.intern_mem(parent, node)
|
|
}
|
|
|
|
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
|
|
/// only. Every caller owes the DB a write-through — see the callers below;
|
|
/// the split exists so a batch can pay for one transaction instead of `n`.
|
|
pub(crate) fn intern_mem(&mut self, parent: u64, node: Node) -> u64 {
|
|
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
|
if let Some(e) = self.entries.get_mut(&ino) {
|
|
e.node = node;
|
|
e.parent = parent;
|
|
}
|
|
return ino;
|
|
}
|
|
let ino = self.next_ino;
|
|
self.next_ino += 1;
|
|
self.by_uid.insert(node.uid.clone(), ino);
|
|
self.entries.insert(
|
|
ino,
|
|
Entry {
|
|
uid: node.uid.clone(),
|
|
parent,
|
|
node,
|
|
},
|
|
);
|
|
ino
|
|
}
|
|
|
|
/// Allocate (or reuse) a stable inode for a node that came *from* the
|
|
/// database, which is why nothing is written back.
|
|
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
|
|
self.intern_mem(parent, node)
|
|
}
|
|
|
|
/// Allocate (or reuse) stable inodes for a whole listing, writing every node
|
|
/// through in a single transaction.
|
|
///
|
|
/// This is what keeps `ls` on a large folder quick: one commit for the
|
|
/// listing, rather than one autocommit — and one fsync — per child.
|
|
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
|
if let Err(e) = self.db.upsert_nodes(&nodes) {
|
|
warn!(error = %e, "db upsert_nodes failed");
|
|
}
|
|
nodes
|
|
.into_iter()
|
|
.map(|node| self.intern_mem(parent, node))
|
|
.collect()
|
|
}
|
|
|
|
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
|
|
/// listing, its slot in its parent's listing, and its DB row. Returns
|
|
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
|
/// the kernel.
|
|
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
|
let ino = self.by_uid.remove(uid)?;
|
|
if let Err(e) = self.db.delete_node(uid) {
|
|
warn!(%uid, error = %e, "db delete_node failed");
|
|
}
|
|
let entry = self.entries.remove(&ino)?;
|
|
self.children.remove(&ino);
|
|
if let Some(kids) = self.children.get_mut(&entry.parent) {
|
|
kids.retain(|&k| k != ino);
|
|
}
|
|
Some((entry.parent, entry.node.name))
|
|
}
|
|
|
|
/// Move a node to a new parent and/or name within the tree, writing it
|
|
/// through like any other mutation.
|
|
///
|
|
/// The online rename instead forgets the node and lets the destination
|
|
/// re-enumerate, which is the cheaper way to stay honest about what the
|
|
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
|
/// the server has not been told yet in any case — so this *is* the tree's
|
|
/// new truth until the op drains (offline.md Phase 3b).
|
|
pub(crate) fn rename_in_place(
|
|
&mut self,
|
|
ino: u64,
|
|
new_parent: u64,
|
|
new_parent_uid: &NodeUid,
|
|
name: &str,
|
|
) {
|
|
let Some(entry) = self.entries.get_mut(&ino) else {
|
|
return;
|
|
};
|
|
let old_parent = entry.parent;
|
|
entry.parent = new_parent;
|
|
entry.node.name = name.to_string();
|
|
entry.node.parent_uid = Some(new_parent_uid.clone());
|
|
let node = entry.node.clone();
|
|
if old_parent != new_parent {
|
|
if let Some(kids) = self.children.get_mut(&old_parent) {
|
|
kids.retain(|&k| k != ino);
|
|
}
|
|
// Only if the destination is listed: pushing into a listing that was
|
|
// never enumerated would invent a one-child folder.
|
|
if let Some(kids) = self.children.get_mut(&new_parent)
|
|
&& !kids.contains(&ino)
|
|
{
|
|
kids.push(ino);
|
|
}
|
|
}
|
|
if let Err(e) = self.db.upsert_node(&node) {
|
|
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
|
|
}
|
|
}
|
|
|
|
/// Drop a directory's cached child listing and mark it unlisted in the DB,
|
|
/// so the next access re-enumerates instead of trusting a stale listing.
|
|
///
|
|
/// The DB flag is cleared whether or not the listing was resident. A folder
|
|
/// trimmed from the hot cache but still `listed` in the DB is exactly the
|
|
/// case that needs invalidating — returning early there would leave
|
|
/// `ensure_children` free to rebuild the stale listing from disk.
|
|
pub(crate) fn invalidate_listing(&mut self, ino: u64) {
|
|
self.children.remove(&ino);
|
|
if let Some(e) = self.entries.get(&ino) {
|
|
let uid = e.uid.clone();
|
|
if let Err(err) = self.db.set_listed(&uid, false) {
|
|
warn!(%uid, error = %err, "db set_listed(false) failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Settle the local state after a node has been moved (and possibly renamed)
|
|
/// on the remote: rewrite it in place, and drop **both** directories'
|
|
/// listings so each re-enumerates.
|
|
///
|
|
/// Both, because each is stale for its own reason. The destination has
|
|
/// gained a child it does not know about; the source has lost one. The
|
|
/// source is the subtler half, and was audit A5: pruning the moved node from
|
|
/// the source's in-memory children looks sufficient while that entry stays
|
|
/// resident, but the source's DB row is left `listed = 1`. Once it is
|
|
/// evicted from the hot cache, or the daemon restarts, `ensure_children`
|
|
/// rebuilds the listing from the DB and declares it complete, so anything
|
|
/// else that changed remotely under the source since is never seen.
|
|
///
|
|
/// What this must *not* do is `forget` the node. Forgetting drops its
|
|
/// `by_uid` mapping, so the re-enumeration hands it a fresh inode — while
|
|
/// the kernel has already carried the renamed dentry over to the *old* one.
|
|
/// Every lookup through that dentry then resolves to an inode `entries` no
|
|
/// longer holds and fails `ENOENT`, so the renamed directory reads as
|
|
/// missing (`ls` on it errors while `ls` of its parent lists it) until the
|
|
/// entry TTL expires. Keeping the inode keeps the kernel's dentry valid.
|
|
///
|
|
/// A pure rename (`from == to`) invalidates that one directory once.
|
|
pub(crate) fn relocate(
|
|
&mut self,
|
|
ino: u64,
|
|
from_parent: u64,
|
|
to_parent: u64,
|
|
to_parent_uid: &NodeUid,
|
|
name: &str,
|
|
) {
|
|
self.rename_in_place(ino, to_parent, to_parent_uid, name);
|
|
self.invalidate_listing(to_parent);
|
|
if from_parent != to_parent {
|
|
self.invalidate_listing(from_parent);
|
|
}
|
|
}
|
|
|
|
/// Update a file entry's recorded plaintext size so `getattr` reflects an
|
|
/// in-progress write before the new revision is sealed.
|
|
pub(crate) fn set_size(&mut self, ino: u64, size: u64) {
|
|
if let Some(e) = self.entries.get_mut(&ino)
|
|
&& let NodeKind::File { claimed_size, .. } = &mut e.node.kind
|
|
{
|
|
*claimed_size = Some(size as i64);
|
|
}
|
|
}
|
|
|
|
/// Update a file entry's modification time (epoch seconds).
|
|
pub(crate) fn touch_mtime(&mut self, ino: u64, secs: i64) {
|
|
if let Some(e) = self.entries.get_mut(&ino) {
|
|
e.node.modification_time = secs;
|
|
}
|
|
}
|
|
|
|
/// Record the size and mtime of a write that has been accepted but not yet
|
|
/// uploaded, persisting them like any other node mutation.
|
|
///
|
|
/// The write-through is what makes the new size outlive the process: until
|
|
/// the op drains, the remote still holds the old revision (or, for a node
|
|
/// created offline, nothing at all), so this row is the only record that the
|
|
/// file is as long as the caller was told it is. Without it a restart serves
|
|
/// the stale size and the file reads as truncated — or empty — while its
|
|
/// bytes sit safely in staging (offline.md Phase 3).
|
|
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) {
|
|
self.set_size(ino, size);
|
|
self.touch_mtime(ino, mtime);
|
|
if let Some(e) = self.entries.get(&ino)
|
|
&& let Err(err) = self.db.upsert_node(&e.node)
|
|
{
|
|
warn!(uid = %e.uid, error = %err, "db upsert_node failed for a queued write");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
|
|
|
fn uid(link: &str) -> NodeUid {
|
|
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
|
}
|
|
|
|
fn node(link: &str, parent: &str, name: &str, is_dir: bool) -> Node {
|
|
Node {
|
|
uid: uid(link),
|
|
parent_uid: Some(uid(parent)),
|
|
kind: if is_dir {
|
|
NodeKind::Folder
|
|
} else {
|
|
NodeKind::File {
|
|
media_type: "text/plain".into(),
|
|
total_size_on_storage: 0,
|
|
active_revision_state: None,
|
|
claimed_size: Some(0),
|
|
claimed_modification_time: None,
|
|
}
|
|
},
|
|
name: name.into(),
|
|
creation_time: 100,
|
|
modification_time: 100,
|
|
trashed: false,
|
|
is_shared: false,
|
|
is_shared_publicly: false,
|
|
signature_email: None,
|
|
verification: Default::default(),
|
|
}
|
|
}
|
|
|
|
/// A unique temp directory removed on drop; avoids a dev-dependency.
|
|
struct TempDir(PathBuf);
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
let p = std::env::temp_dir().join(format!(
|
|
"pdfs-state-{}-{}",
|
|
std::process::id(),
|
|
N.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
TempDir(p)
|
|
}
|
|
}
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
/// `Db::open_in_memory` is `#[cfg(test)]` inside `pdfs-core`, so it does not
|
|
/// exist for this crate's tests — a temp file is the equivalent here. The
|
|
/// directory outlives the state because the state holds the open database.
|
|
fn state() -> (State, TempDir) {
|
|
let dir = TempDir::new();
|
|
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
|
let st = State {
|
|
entries: HashMap::new(),
|
|
by_uid: HashMap::new(),
|
|
children: HashMap::new(),
|
|
next_ino: 1,
|
|
handles: HashMap::new(),
|
|
next_fh: 1,
|
|
db: Arc::new(db),
|
|
};
|
|
(st, dir)
|
|
}
|
|
|
|
/// Two folders, each holding one file, both listed — the state a move
|
|
/// starts from. Returns `(state, src_ino, dst_ino)`.
|
|
fn two_folders() -> (State, TempDir, u64, u64) {
|
|
let (mut st, dir) = state();
|
|
let src = st.intern(0, node("src", "root", "src", true));
|
|
let dst = st.intern(0, node("dst", "root", "dst", true));
|
|
let f = st.intern(src, node("f", "src", "f.txt", false));
|
|
st.children.insert(src, vec![f]);
|
|
st.children.insert(dst, vec![]);
|
|
st.db.set_listed(&uid("src"), true).unwrap();
|
|
st.db.set_listed(&uid("dst"), true).unwrap();
|
|
(st, dir, src, dst)
|
|
}
|
|
|
|
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
|
/// claiming a complete listing, so once the hot cache drops it, remote
|
|
/// changes under it stop being seen. `relocate` has to clear both sides.
|
|
#[test]
|
|
fn relocate_invalidates_the_source_as_well_as_the_destination() {
|
|
let (mut st, _dir, src, dst) = two_folders();
|
|
let f = st.by_uid[&uid("f")];
|
|
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
|
|
|
assert!(
|
|
st.db.children_if_listed(&uid("dst")).unwrap().is_none(),
|
|
"destination re-enumerates: it gained a child whose row was just deleted"
|
|
);
|
|
assert!(
|
|
st.db.children_if_listed(&uid("src")).unwrap().is_none(),
|
|
"source re-enumerates: its listing predates the move, and a stale \
|
|
`listed` flag would hide every later remote change under it"
|
|
);
|
|
assert!(!st.children.contains_key(&src));
|
|
assert!(!st.children.contains_key(&dst));
|
|
assert_eq!(
|
|
st.by_uid.get(&uid("f")),
|
|
Some(&f),
|
|
"the inode survives the move: the kernel has already pointed the \
|
|
renamed dentry at it, so re-interning under a fresh one would make \
|
|
every lookup through that dentry ENOENT"
|
|
);
|
|
assert_eq!(st.entries[&f].parent, dst);
|
|
}
|
|
|
|
/// A pure rename never leaves the directory, so there is one listing to
|
|
/// drop, not two — and dropping it must not depend on the parents differing.
|
|
#[test]
|
|
fn relocate_within_one_directory_still_invalidates_it() {
|
|
let (mut st, _dir, src, _dst) = two_folders();
|
|
let f = st.by_uid[&uid("f")];
|
|
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
|
assert!(st.db.children_if_listed(&uid("src")).unwrap().is_none());
|
|
assert!(!st.children.contains_key(&src));
|
|
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
|
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
|
}
|
|
|
|
/// The failure mode the fix exists for, stated directly: forgetting the node
|
|
/// prunes it from the resident listing but leaves the DB claiming the source
|
|
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
|
#[test]
|
|
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
|
let (mut st, _dir, _src, _dst) = two_folders();
|
|
st.forget(&uid("f"));
|
|
let listed = st.db.children_if_listed(&uid("src")).unwrap();
|
|
assert!(
|
|
listed.is_some(),
|
|
"forget does not clear the flag — which is exactly why relocate must"
|
|
);
|
|
assert!(
|
|
listed.unwrap().is_empty(),
|
|
"and the listing it would serve is the moved file's absence, \
|
|
with no way to notice anything else changed"
|
|
);
|
|
}
|
|
}
|