feat(fuse,core): record namespace changes locally and send them from the queue

A new folder, a new file, a rename or a delete through the mount no longer
waits for Drive: it is recorded locally, the call returns at once, and the
drain sends it as it does offline. `"local_first": false` in config.json
brings back the old path for this release.

Fixes found by the simulation runs on the way:
- B129: a file deleted while its first upload is on the wire is trashed once
  the upload lands.
- B132, B140: files made in a new folder survive a restart; a landed folder
  hands its queued children over in the stored node too.
- B138: a create waits for the rename or trash that frees its name.
- B139: a file closed while its first upload lands keeps its last write.
- B141: a rename a worker holds stays queued, and the newer one waits behind
  it instead of racing it.
- B142: a rename of a file whose create lands meanwhile goes to the uid Drive
  gave it instead of answering EBUSY.
- B143: a create whose read-back fails as the link drops is adopted before
  the next op for it is sent, so its next write is not a conflict copy.
This commit is contained in:
nvrl committed 2026-10-04 01:50:38 +02:00
1 parent b3e33a7002
commit 5e0f30cc2c
17 files changed
+1271 -111

No files matched your search

+1
View File
@@ -1983,6 +1983,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
sweep_mode: config.resolved_conflict_sweep(),
upload_limit: config.upload_limit.unwrap_or(0),
download_limit: config.download_limit.unwrap_or(0),
local_first: config.local_first.unwrap_or(true),
},
);
+9
View File
@@ -154,6 +154,13 @@ pub struct AppConfig {
/// configs predating the field.
#[serde(default)]
pub online_map: bool,
/// Record a new folder, a new file, a rename or a delete through the mount
/// locally and send it from the queue, instead of waiting for Drive in the
/// syscall. `None` means on. `false` brings back the old path for one
/// release (docs/MILESTONE-3.0.0.md §10). Defaulted for configs predating
/// the field.
#[serde(default)]
pub local_first: Option<bool>,
}
/// The My Files page's view choices, remembered between runs.
@@ -228,6 +235,7 @@ impl Default for AppConfig {
tray_hidden: false,
language: None,
online_map: false,
local_first: None,
}
}
}
@@ -584,6 +592,7 @@ mod tests {
tray_hidden: true,
language: Some("de".to_string()),
online_map: true,
local_first: Some(false),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
+3 -3
View File
@@ -56,9 +56,9 @@ pub use local::LocalFileHit;
pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
pub use ops::{
AttachedBlob, CreateLanding, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME,
OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta,
op_supersedes,
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
+73 -2
View File
@@ -6,6 +6,7 @@ use std::collections::HashSet;
use rusqlite::{OptionalExtension, Transaction, params};
use super::Db;
use super::ops::{OP_CREATE, OP_MKDIR, OP_RENAME, wake_ops_waiting_for_tx};
use crate::{Access, Result};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
@@ -266,10 +267,22 @@ impl Db {
/// resolve its shared-tree access. Removing the op first could resurrect
/// that row after a crash; removing the row first could strand a now
/// unauthorizable op. One transaction closes both windows.
///
/// An op that found the name still held by this trash failed with a
/// backoff, and the name is free now. It is made due at once
/// ([`Db::wake_ops_waiting_for`]).
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let name: Option<String> = tx
.query_row(
"SELECT name FROM pending_op WHERE id = ?1",
params![op_id],
|row| row.get(0),
)
.optional()?
.flatten();
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
@@ -282,6 +295,9 @@ impl Db {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
if let Some(name) = name {
wake_ops_waiting_for_tx(&tx, &name)?;
}
tx.commit()?;
Ok(())
}
@@ -549,6 +565,18 @@ impl Db {
}
}
/// The uid of the node with local id `lid`: `None` when it has no row, or
/// is not on Drive yet.
pub fn uid_of_lid(&self, lid: i64) -> Result<Option<String>> {
let conn = self.read();
Ok(conn
.query_row("SELECT uid FROM nodes WHERE lid = ?1", params![lid], |r| {
r.get(0)
})
.optional()?
.flatten())
}
/// The local id of the node stored under `uid`, or `None` when it has no
/// row. It stays the same for as long as the row exists, including when a
/// create lands and the row takes the uid Drive gave it.
@@ -636,6 +664,40 @@ impl Db {
Ok(out)
}
/// What a queued change does to `parent`'s listing, which Drive does not
/// show until it lands: the children made or moved in by a queued create,
/// mkdir or rename, as the DB holds them, and the nodes a queued rename
/// moves elsewhere.
///
/// A listing read from Drive is laid over with these, or a folder listed
/// while a create in it is queued, after a restart say, loses the file
/// until the create lands (`docs/BUGS.md` B132).
pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> {
let conn = self.read();
let parent = parent.to_string();
let mut stmt = conn.prepare(
"SELECT n.node_json FROM nodes n
WHERE n.parent_uid = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0
AND EXISTS (SELECT 1 FROM pending_op p
WHERE p.uid = n.uid AND p.kind IN (?2, ?3, ?4))",
)?;
let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| {
r.get::<_, String>(0)
})?;
let mut here = Vec::new();
for json in rows {
here.push(serde_json::from_str(&json?)?);
}
let mut stmt = conn.prepare(
"SELECT DISTINCT p.uid FROM pending_op p JOIN nodes n ON n.uid = p.uid
WHERE p.kind = ?2 AND n.parent_uid IS NOT ?1",
)?;
let gone = stmt
.query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))?
.collect::<std::result::Result<HashSet<_>, _>>()?;
Ok((here, gone))
}
// --- Content-cache LRU index (P4) -------------------------------------
//
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
@@ -671,9 +733,18 @@ pub(super) fn adopt_placeholder_row_tx(
local: &str,
real: &str,
) -> Result<()> {
// The node a row holds names its parent too, and is what a restart builds
// the tree from: a folder that landed with no write to its children after
// left them under a parent no row has (`docs/BUGS.md` B140).
let real_json = parse_node_uid(real)
.map(|uid| serde_json::to_string(&uid))
.transpose()?;
tx.execute(
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
"UPDATE nodes SET parent_uid = ?2,
node_json = CASE WHEN ?3 IS NULL OR node_json IS NULL THEN node_json
ELSE json_set(node_json, '$.parent_uid', json(?3)) END
WHERE parent_uid = ?1",
params![local, real, real_json],
)?;
let row: Option<(i64, Option<String>)> = tx
.query_row(
+174 -11
View File
@@ -92,6 +92,13 @@ pub fn op_supersedes(kind: &str) -> bool {
/// Other ops are taken from the node tree only for revisions. A queued rename
/// that moved a node *into* the folder has to land, or the node would stay
/// where it was on the server instead of going to the trash with the folder.
///
/// A create already on the wire cannot be stopped, so its row is not deleted
/// but turned into a trash of the node it is making, still claimed by the
/// upload. [`Db::finish_create`] points it at the landed node. Until then it
/// holds the name, as any queued trash does ([`Db::has_pending_trash_named`]):
/// a rename over the file sent first found the name taken and landed under a
/// conflict name (`docs/BUGS.md` B129).
fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<String>> {
const DOOMED: &str = "
WITH RECURSIVE
@@ -124,12 +131,48 @@ fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<Stri
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute(
&format!("{DOOMED} DELETE FROM pending_op WHERE id IN (SELECT id FROM doomed)"),
&format!(
"{DOOMED}
UPDATE pending_op
SET kind = 'trash', parent_uid = NULL, blob_path = NULL, meta_json = NULL
WHERE id IN (SELECT id FROM doomed)
AND kind IN ('create', 'mkdir') AND claimed_at <> 0"
),
params![uid],
)?;
tx.execute(
&format!(
"{DOOMED}
DELETE FROM pending_op
WHERE id IN (SELECT id FROM doomed)
AND NOT (kind = 'trash' AND claimed_at <> 0 AND uid LIKE '{LOCAL_VOLUME}~%')"
),
params![uid],
)?;
Ok(blobs)
}
/// The `pending_op` rows, `?1` being [`OP_RENAME`], that move a node away from
/// the name `?2`.
const MOVES_FROM: &str = "kind = ?1
AND json_extract(meta_json, '$.original_name') = ?2
AND (name <> ?2 OR parent_uid <> json_extract(meta_json, '$.original_parent_uid'))";
/// [`Db::wake_ops_waiting_for`] within a transaction.
pub(super) fn wake_ops_waiting_for_tx(tx: &rusqlite::Transaction<'_>, name: &str) -> Result<()> {
tx.execute(
&format!(
"UPDATE pending_op SET next_attempt_at = 0
WHERE name = ?2 AND kind IN (?3, ?4, ?1) AND attempts > 0
AND next_attempt_at < ?5
AND NOT EXISTS (SELECT 1 FROM pending_op WHERE kind = ?6 AND name = ?2)
AND NOT EXISTS (SELECT 1 FROM pending_op WHERE {MOVES_FROM})"
),
params![OP_RENAME, name, OP_CREATE, OP_MKDIR, PARK_UNTIL, OP_TRASH],
)?;
Ok(())
}
/// The volume id given to a node that exists only on this machine, so far. A
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<uuid>` and
/// round-trips through the same `Display`/parse path as any other uid.
@@ -242,6 +285,20 @@ pub struct CreateLanding<'a> {
pub landed: (&'a str, &'a str),
}
/// What became of a queued create when it landed, from [`Db::finish_create`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreateRetired {
/// Its row is gone and nothing of it is left queued.
Landed,
/// A newer write attached to it on the wire and is now queued as a
/// revision of the real node: its blob and its rewritten sidecar.
Newer { blob: String, meta: String },
/// Its row had been dropped while it was on the wire: the node was deleted,
/// or a rename replaced it. The node Drive just made is unwanted, and a
/// trash of it is queued under this id, claimed by the caller.
Withdrawn { trash: i64 },
}
struct CreateRow {
blob: Option<String>,
meta: Option<String>,
@@ -278,6 +335,32 @@ impl Db {
.map_err(Into::into)
}
/// Whether a queued rename moves a node away from `name`.
///
/// Like a queued trash ([`Db::has_pending_trash_named`]) it frees the name
/// once it lands, so an op that wants the name can wait for it. A create
/// renamed while it was on the wire queues one (`Db::finish_create`).
pub fn has_pending_move_from(&self, name: &str) -> Result<bool> {
let conn = self.read();
conn.query_row(
&format!("SELECT EXISTS(SELECT 1 FROM pending_op WHERE {MOVES_FROM})"),
params![OP_RENAME, name],
|row| row.get(0),
)
.map_err(Into::into)
}
/// Make the ops that failed because `name` was held due at once, now that
/// what held it let go. Nothing changes while another queued trash or
/// rename still holds it, or for a parked op.
pub fn wake_ops_waiting_for(&self, name: &str) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
wake_ops_waiting_for_tx(&tx, name)?;
tx.commit()?;
Ok(())
}
/// Whether a specific desired-state operation is still queued for a node.
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
let conn = self.read();
@@ -304,21 +387,29 @@ impl Db {
.map_err(Into::into)
}
/// Queue `op`, returning its id and the staged blob of the op it replaced.
///
/// A rename a drain worker holds is not replaced but queued behind: the
/// claim keeps the newer one from being sent until it retires, so the two
/// land in the order they were made (`docs/BUGS.md` B141). Should it fail,
/// [`Db::record_op_failure`] drops it.
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let superseded: Option<String> = if op_supersedes(&op.kind) {
let blob: Option<String> = tx
.query_row(
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, op.kind],
"SELECT blob_path FROM pending_op
WHERE uid = ?1 AND kind = ?2 AND (kind <> ?3 OR claimed_at = 0)",
params![op.uid, op.kind, OP_RENAME],
|r| r.get(0),
)
.optional()?
.flatten();
tx.execute(
"DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, op.kind],
"DELETE FROM pending_op
WHERE uid = ?1 AND kind = ?2 AND (kind <> ?3 OR claimed_at = 0)",
params![op.uid, op.kind, OP_RENAME],
)?;
blob
} else {
@@ -429,21 +520,28 @@ impl Db {
/// row names another parent or name than `landing.sent`, a rename of `real`
/// to that target is queued in the same transaction, or the move would be
/// lost with the row.
///
/// A delete while the create is on the wire cannot stop the upload. It
/// turns the row into a trash ([`Db::delete_ops_for_uid`]), which is
/// pointed at `real` here, still claimed by the caller, so the node is not
/// left on Drive holding the name (`docs/BUGS.md` B129). A row gone
/// altogether is queued as such a trash. The placeholder row is left to
/// the delete that withdrew it.
pub fn finish_create(
&self,
id: i64,
uploaded: Option<&str>,
landing: &CreateLanding<'_>,
rewrite: impl FnOnce(&str) -> Option<String>,
) -> Result<Option<(String, String)>> {
) -> Result<CreateRetired> {
let (local, real) = (landing.local, landing.real);
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let row: Option<CreateRow> = tx
.query_row(
"SELECT blob_path, meta_json, parent_uid, name, created_at
FROM pending_op WHERE id = ?1",
params![id],
FROM pending_op WHERE id = ?1 AND kind <> ?2",
params![id, OP_TRASH],
|r| {
Ok(CreateRow {
blob: r.get(0)?,
@@ -455,6 +553,28 @@ impl Db {
},
)
.optional()?;
let withdrawn = tx.execute(
"UPDATE pending_op SET uid = ?2, name = ?3 WHERE id = ?1 AND kind = ?4",
params![id, real, landing.landed.1, OP_TRASH],
)?;
if withdrawn > 0 {
tx.commit()?;
return Ok(CreateRetired::Withdrawn { trash: id });
}
if row.is_none() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(1, |d| d.as_millis() as i64);
tx.execute(
"INSERT INTO pending_op
(kind, uid, parent_uid, name, created_at, next_attempt_at, claimed_at)
VALUES (?1, ?2, NULL, ?3, ?4, 0, ?4)",
params![OP_TRASH, real, landing.landed.1, now],
)?;
let trash = tx.last_insert_rowid();
tx.commit()?;
return Ok(CreateRetired::Withdrawn { trash });
}
let newer = match &row {
Some(CreateRow {
blob: Some(blob),
@@ -505,7 +625,10 @@ impl Db {
)?;
adopt_placeholder_row_tx(&tx, local, real)?;
tx.commit()?;
Ok(newer)
Ok(match newer {
Some((blob, meta)) => CreateRetired::Newer { blob, meta },
None => CreateRetired::Landed,
})
}
/// Replace the sidecar of a queued op, for a baseline that has moved under
@@ -539,12 +662,17 @@ impl Db {
/// *is* the rename. Returns false when the create has already drained, in
/// which case the node has a real uid and the caller must rename it there
/// instead (offline.md Phase 3b).
///
/// A create backing off is made due: what it failed on may have been the
/// name it no longer wants, such as one held by a queued change. A parked
/// create stays parked.
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET parent_uid = ?2, name = ?3
"UPDATE pending_op SET parent_uid = ?2, name = ?3,
next_attempt_at = CASE WHEN next_attempt_at < ?6 THEN 0 ELSE next_attempt_at END
WHERE uid = ?1 AND kind IN (?4, ?5)",
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR],
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR, PARK_UNTIL],
)?;
Ok(n > 0)
}
@@ -978,8 +1106,22 @@ impl Db {
/// Clears any access-deferral window: whatever the op was waiting for has
/// now been reported, so the next uncleared deferral starts its own window
/// rather than escalating again on the following recheck.
///
/// A rename with a newer one queued behind it ([`Db::enqueue_op`]) is
/// dropped instead: retried, it could land after the newer one and undo it.
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
let conn = self.conn.lock();
let dropped = conn.execute(
"DELETE FROM pending_op
WHERE id = ?1 AND kind = ?2
AND EXISTS (SELECT 1 FROM pending_op AS newer
WHERE newer.uid = pending_op.uid AND newer.kind = ?2
AND newer.id > ?1)",
params![id, OP_RENAME],
)?;
if dropped > 0 {
return Ok(());
}
conn.execute(
"UPDATE pending_op
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
@@ -990,6 +1132,27 @@ impl Db {
Ok(())
}
/// Make a create due again if it was renamed or moved
/// ([`Db::rewrite_op_target`]) while the attempt that just failed was on
/// the wire, `parent_uid` and `name` being what that attempt sent. What it
/// failed on may have been the target it no longer has. Returns whether it
/// was.
pub fn retry_if_retargeted(
&self,
id: i64,
parent_uid: Option<&str>,
name: Option<&str>,
) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET next_attempt_at = 0
WHERE id = ?1 AND kind IN (?4, ?5) AND next_attempt_at < ?6
AND (parent_uid IS NOT ?2 OR name IS NOT ?3)",
params![id, parent_uid, name, OP_CREATE, OP_MKDIR, PARK_UNTIL],
)?;
Ok(n > 0)
}
/// Defer a due operation without recording a failed remote attempt.
///
/// Access checks happen before the drain calls Proton, so a locally denied
+358 -5
View File
@@ -2474,6 +2474,125 @@ fn a_queued_trash_holds_its_name_until_it_lands() {
assert!(!db.has_pending_trash_named("report.txt").unwrap());
}
/// A create that found its name held by a queued trash backed off. The trash
/// landing frees the name, so the create is due at once instead of after the
/// backoff; a parked create stays parked.
#[test]
fn a_landed_trash_makes_the_ops_waiting_for_its_name_due() {
let db = Db::open_in_memory().unwrap();
let old = uid("old");
db.upsert_node(&file("old", "root", "report.txt", 1))
.unwrap();
let (trash_id, _) = db
.replace_ops_with_trash(&old.to_string(), "report.txt", 1)
.unwrap();
let create = |name: &str, next_attempt_at: i64| {
let (id, _) = db
.enqueue_op(&PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: format!("local~{name}-{next_attempt_at}"),
parent_uid: Some(uid("root").to_string()),
name: Some(name.to_string()),
blob_path: None,
meta_json: None,
created_at: 2,
attempts: 0,
last_error: None,
next_attempt_at,
})
.unwrap();
db.record_op_failure(id, "report.txt is still held", next_attempt_at)
.unwrap();
id
};
let waiting = create("report.txt", 9_000);
let parked = create("report.txt", PARK_UNTIL);
let other = create("other.txt", 9_000);
db.complete_trash_op(trash_id, &old).unwrap();
let due = |id| {
db.pending_ops()
.unwrap()
.into_iter()
.find(|op| op.id == id)
.unwrap()
.next_attempt_at
};
assert_eq!(due(waiting), 0);
assert_eq!(due(parked), PARK_UNTIL);
assert_eq!(due(other), 9_000);
}
#[test]
fn a_queued_rename_away_from_a_name_holds_it_until_it_lands() {
let db = Db::open_in_memory().unwrap();
let root = uid("root").to_string();
let rename = |node: &str, from: &str, to: &str| {
let meta = serde_json::to_string(&RenameMeta {
original_parent_uid: root.clone(),
original_name: Some(from.to_string()),
})
.unwrap();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_RENAME.to_string(),
uid: uid(node).to_string(),
parent_uid: Some(root.clone()),
name: Some(to.to_string()),
blob_path: None,
meta_json: Some(meta),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap()
.0
};
let away = rename("away", "c.bin", "d");
rename("back", "e.md", "e.md");
let (waiting, _) = db
.enqueue_op(&PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: "local~waiting".to_string(),
parent_uid: Some(root.clone()),
name: Some("c.bin".to_string()),
blob_path: None,
meta_json: None,
created_at: 2,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
db.record_op_failure(waiting, "c.bin is still held", 9_000)
.unwrap();
assert!(db.has_pending_move_from("c.bin").unwrap());
assert!(!db.has_pending_move_from("d").unwrap());
assert!(
!db.has_pending_move_from("e.md").unwrap(),
"a rename that keeps the name in its folder frees nothing"
);
db.wake_ops_waiting_for("c.bin").unwrap();
let due = || {
db.pending_ops()
.unwrap()
.into_iter()
.find(|op| op.id == waiting)
.unwrap()
.next_attempt_at
};
assert_eq!(due(), 9_000, "the rename still holds the name");
db.delete_op(away).unwrap();
db.wake_ops_waiting_for("c.bin").unwrap();
assert_eq!(due(), 0);
}
#[test]
fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
let db = Db::open_in_memory().unwrap();
@@ -2796,6 +2915,106 @@ fn renaming_a_queued_create_rewrites_its_target() {
);
}
/// A create that failed on a name held by a queued change waits out a
/// backoff. Renamed away from that name, it is due again at once; parked, it
/// stays parked.
#[test]
fn renaming_a_backing_off_create_makes_it_due() {
let db = Db::open_in_memory().unwrap();
let create = |local: &str| PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: local.to_string(),
parent_uid: Some(uid("dir").to_string()),
name: Some("a.txt".to_string()),
blob_path: Some(format!("/staging/{local}")),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
let (failed, _) = db.enqueue_op(&create("local~failed")).unwrap();
db.record_op_failure(failed, "a.txt is still held", 10_000)
.unwrap();
db.enqueue_op(&create("local~parked")).unwrap();
db.set_create_hold("local~parked", true).unwrap();
db.rewrite_op_target("local~failed", &uid("dir").to_string(), "d")
.unwrap();
db.rewrite_op_target("local~parked", &uid("dir").to_string(), "e.tmp")
.unwrap();
let ops = db.pending_ops().unwrap();
let op = |local: &str| ops.iter().find(|o| o.uid == local).unwrap();
assert_eq!(op("local~failed").next_attempt_at, 0);
assert_eq!(op("local~failed").attempts, 1);
assert_eq!(op("local~parked").next_attempt_at, PARK_UNTIL);
// Renamed while an attempt was on the wire, which then failed: the
// failure is recorded after the rename, and the create is still due.
let sent = op("local~failed").clone();
db.rewrite_op_target("local~failed", &uid("dir").to_string(), "f")
.unwrap();
db.record_op_failure(sent.id, "d is still held", 10_000)
.unwrap();
assert!(
db.retry_if_retargeted(sent.id, sent.parent_uid.as_deref(), sent.name.as_deref())
.unwrap()
);
let ops = db.pending_ops().unwrap();
assert_eq!(
ops.iter()
.find(|o| o.id == sent.id)
.unwrap()
.next_attempt_at,
0
);
assert!(
!db.retry_if_retargeted(sent.id, sent.parent_uid.as_deref(), Some("f"))
.unwrap()
);
}
/// A rename queued while an older one is on the wire waits for it. The older
/// one, if it fails, is dropped rather than retried after the newer one.
#[test]
fn a_rename_on_the_wire_lands_before_the_one_that_replaces_it() {
let db = Db::open_in_memory().unwrap();
let file = uid("file").to_string();
let rename = |parent: &str| PendingOp {
id: 0,
kind: OP_RENAME.to_string(),
uid: file.clone(),
parent_uid: Some(uid(parent).to_string()),
name: Some("e.md".to_string()),
blob_path: None,
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
let (first, _) = db.enqueue_op(&rename("x")).unwrap();
assert_eq!(db.claim_next_due_op(1).unwrap().unwrap().id, first);
let (second, _) = db.enqueue_op(&rename("c0")).unwrap();
assert!(db.claim_next_due_op(1).unwrap().is_none());
db.record_op_failure(first, "original hash is out of date", 10_000)
.unwrap();
db.release_op_claim(first).unwrap();
let ops = db.pending_ops().unwrap();
assert_eq!(ops.iter().map(|o| o.id).collect::<Vec<_>>(), [second]);
assert_eq!(db.claim_next_due_op(1).unwrap().unwrap().id, second);
// Unclaimed, a queued rename is replaced as before.
db.release_op_claim(second).unwrap();
let (third, _) = db.enqueue_op(&rename("x")).unwrap();
let ops = db.pending_ops().unwrap();
assert_eq!(ops.iter().map(|o| o.id).collect::<Vec<_>>(), [third]);
}
#[test]
fn a_write_folds_into_a_queued_create_instead_of_superseding_it() {
let db = Db::open_in_memory().unwrap();
@@ -2885,10 +3104,10 @@ fn a_write_attached_while_its_create_uploads_becomes_a_revision() {
assert_eq!(
newer,
Some((
"/staging/written".to_string(),
"{\"uid\":\"vol~real\"}".to_string()
))
CreateRetired::Newer {
blob: "/staging/written".to_string(),
meta: "{\"uid\":\"vol~real\"}".to_string()
}
);
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1, "the write is still queued");
@@ -2943,7 +3162,7 @@ fn a_create_whose_blob_landed_is_retired() {
)
.unwrap();
assert_eq!(newer, None);
assert_eq!(newer, CreateRetired::Landed);
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1, "only the child is left");
assert_eq!(ops[0].parent_uid.as_deref(), Some(real.as_str()));
@@ -3102,6 +3321,60 @@ fn a_landed_folder_keeps_a_row_until_the_server_copy_replaces_it() {
assert_eq!(found[0].path, "New folder/inside.txt");
}
/// A file deleted while its create was on the wire still lands: nothing stops
/// an upload in flight. The node it made is unwanted, so a trash of it is
/// queued in the same transaction, already claimed by the drain that landed it
/// (`docs/BUGS.md` B129).
#[test]
fn a_create_withdrawn_while_it_uploads_queues_a_trash_of_the_landed_node() {
let db = Db::open_in_memory().unwrap();
let local = "local~gone";
let real = uid("real").to_string();
let parent = uid("parent").to_string();
let (id, _) = db
.enqueue_op(&PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: local.to_string(),
parent_uid: Some(parent.clone()),
name: Some("d".to_string()),
blob_path: Some("/staging/sent".to_string()),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
db.claim_next_due_op(10)
.unwrap()
.expect("the drain takes the create");
db.delete_ops_for_uid(local).unwrap();
let retired = db
.finish_create(
id,
Some("/staging/sent"),
&landing(local, &real, &parent, "d"),
|_| None,
)
.unwrap();
let CreateRetired::Withdrawn { trash } = retired else {
panic!("a withdrawn create is not landed: {retired:?}");
};
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].id, trash);
assert_eq!(ops[0].kind, OP_TRASH);
assert_eq!(ops[0].uid, real);
assert_eq!(ops[0].name.as_deref(), Some("d"));
assert!(
db.claim_next_due_op(i64::MAX / 2).unwrap().is_none(),
"the trash is the landing drain's to send"
);
}
#[test]
fn a_landed_placeholder_never_overwrites_the_server_copy() {
let db = Db::open_in_memory().unwrap();
@@ -3194,6 +3467,7 @@ fn a_landed_placeholder_keeps_its_lid() {
assert_eq!(db.lid_of(&local.to_string()).unwrap(), None);
assert_eq!(db.lid_of(&real.to_string()).unwrap(), Some(lid));
assert_eq!(db.uid_of_lid(lid).unwrap(), Some(real.to_string()));
let name = if known {
"New folder (1)"
} else {
@@ -3207,6 +3481,85 @@ fn a_landed_placeholder_keeps_its_lid() {
}
}
/// Drive lists none of what is queued for a folder, so a listing read from
/// it is laid over with what the queue made, moved in and moved away (B132).
#[test]
fn queued_children_are_what_drive_does_not_list_yet() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("dir", Some("root"), "dir")).unwrap();
db.upsert_node(&folder("other", Some("root"), "other"))
.unwrap();
let op = |kind: &str, uid: &str, parent: &str, name: &str| PendingOp {
id: 0,
kind: kind.to_string(),
uid: uid.to_string(),
parent_uid: Some(parent.to_string()),
name: Some(name.to_string()),
blob_path: None,
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
let dir = uid("dir").to_string();
let other = uid("other").to_string();
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("new"));
let mut made = file("x", "dir", "made.txt", 3);
made.uid = local.clone();
db.upsert_node(&made).unwrap();
db.enqueue_op(&op(OP_CREATE, &local.to_string(), &dir, "made.txt"))
.unwrap();
db.upsert_node(&file("in", "dir", "in.txt", 3)).unwrap();
db.enqueue_op(&op(OP_RENAME, &uid("in").to_string(), &dir, "in.txt"))
.unwrap();
db.upsert_node(&file("out", "other", "out.txt", 3)).unwrap();
db.enqueue_op(&op(OP_RENAME, &uid("out").to_string(), &other, "out.txt"))
.unwrap();
db.upsert_node(&file("still", "dir", "still.txt", 3))
.unwrap();
let (here, gone) = db.queued_children(&uid("dir")).unwrap();
let mut names: Vec<_> = here.iter().map(|n| n.name.as_str()).collect();
names.sort();
assert_eq!(names, ["in.txt", "made.txt"]);
assert_eq!(
gone,
std::collections::HashSet::from([uid("out").to_string()])
);
}
/// A restart builds the tree from the node each row holds. A folder that
/// landed handed its children to the real uid in the row's column only, so
/// they came back under a parent no row has (B140).
#[test]
fn a_landed_folder_hands_its_children_over_in_the_stored_node_too() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
let mut placeholder = folder("x", Some("root"), "dir");
placeholder.uid = local.clone();
db.upsert_node(&placeholder).unwrap();
let mut child = file("child", "x", "a.txt", 3);
child.parent_uid = Some(local.clone());
db.upsert_node(&child).unwrap();
db.remap_local_uid(&local.to_string(), &uid("dir").to_string())
.unwrap();
let stored = db.node_by_uid(&uid("child").to_string()).unwrap().unwrap();
assert_eq!(stored.parent_uid, Some(uid("dir")));
let names: Vec<_> = db
.known_children(&uid("dir"))
.unwrap()
.into_iter()
.map(|n| n.name)
.collect();
assert_eq!(names, ["a.txt"]);
}
#[test]
fn migration_v36_gives_every_node_its_rowid_as_lid() {
let path = std::env::temp_dir().join(format!(
+266 -44
View File
@@ -33,8 +33,8 @@ use pdfs_core::batch;
use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{
CreateLanding, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS,
PendingOp, RenameMeta,
CreateLanding, CreateRetired, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
PARK_EXPIRY_MS, PendingOp, RenameMeta,
};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeKind};
@@ -203,6 +203,9 @@ fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::e
})
};
match op.kind.as_str() {
// What a withdrawn create that never landed leaves: nothing on Drive
// to ask about, and nothing to send (`Core::drain_trash`).
OP_TRASH if is_local_uid_str(&op.uid) => Ok(Vec::new()),
OP_REVISION | OP_TRASH => Ok(vec![uid()?]),
OP_CREATE | OP_MKDIR => Ok(vec![parent()?]),
OP_RENAME => {
@@ -380,6 +383,19 @@ impl Core {
self.wait_for_drain_work();
continue;
}
match self.db.retry_if_retargeted(
op.id,
op.parent_uid.as_deref(),
op.name.as_deref(),
) {
Ok(true) => {
debug!(uid = %op.uid, "create was renamed while it failed; retrying")
}
Ok(false) => {}
Err(e) => {
debug!(uid = %op.uid, error = %e, "checking a failed create's target failed")
}
}
}
// Unconditionally, on every path: a handler that retired its own row
// leaves nothing to release, and one that returned without retiring
@@ -639,6 +655,7 @@ impl Core {
/// Perform one queued op and retire it.
pub(crate) fn drain_op(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
self.finish_adoption(op)?;
match op.kind.as_str() {
OP_REVISION => self.drain_revision(op),
OP_CREATE | OP_MKDIR => self.drain_local_node(op),
@@ -648,6 +665,29 @@ impl Core {
}
}
/// Finish adopting the node `op` is for, if its create landed but could
/// not be read back.
///
/// Until it is, the tree has the node as it was made locally, and a write
/// queued over it is based on that rather than on what Drive holds. Sent
/// so, the write found our own create's revision in its way and landed as
/// a conflict copy (`docs/BUGS.md` B143).
fn finish_adoption(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
let Some(real) = parse_node_uid(&op.uid) else {
return Ok(());
};
let Some(local) = self.unadopted.lock().get(&real).cloned() else {
return Ok(());
};
// A node gone since is the op's to deal with.
if self.fetch_node_remote(&real)?.is_some() {
self.adopt_real_uid(&local, &real, |_| {})?;
}
self.unadopted.lock().remove(&real);
debug!(%local, %real, "adopted a landed create");
Ok(())
}
/// Apply a queued rename/move to the remote.
///
/// The op is the desired end state, so the remote's current state decides
@@ -675,17 +715,21 @@ impl Core {
return Ok(());
}
};
if let Some(meta) = op.meta_json.as_deref() {
let meta: RenameMeta = serde_json::from_str(meta)?;
if moved_elsewhere(&meta, &node, &parent, &name) {
// Someone moved or renamed it while ours was queued. Ours still
// wins, as the last change does on Drive.
info!(%uid, from = %node.name, to = %name,
"node moved remotely while its rename was queued");
}
let meta: Option<RenameMeta> = op
.meta_json
.as_deref()
.map(serde_json::from_str)
.transpose()?;
if let Some(meta) = &meta
&& moved_elsewhere(meta, &node, &parent, &name)
{
// Someone moved or renamed it while ours was queued. Ours still
// wins, as the last change does on Drive.
info!(%uid, from = %node.name, to = %name,
"node moved remotely while its rename was queued");
}
let landed = if node.parent_uid.as_ref() == Some(&parent) {
self.drain_rename_in_place(&uid, &node.name, &name)?
self.drain_rename_in_place(op, &uid, Some(&parent), &node.name, &name)?
} else {
// Move and rename land as one request (`move-multiple` takes a
// target name), so there is no half-applied state between them and
@@ -698,8 +742,8 @@ impl Core {
// *different* name is the non-destructive resolution: it neither
// clobbers their file nor drops ours, and it is visible.
Err(e) if is_already_exists(&e) => {
if self.db.has_pending_trash_named(&name)? {
return Err(held_by_queued_trash(&name));
if self.name_is_held(op, Some(&parent), &name)? {
return Err(held_by_queued_change(&name));
}
let alt = conflict_name(&name, now_secs());
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
@@ -721,7 +765,13 @@ impl Core {
// wedge the queue.
Err(e) if is_gone(&e) => {
warn!(%uid, name, %parent, "move destination is gone; leaving the node where it is");
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
let landed = match self.drain_rename_in_place(
op,
&uid,
node.parent_uid.as_ref(),
&node.name,
&name,
) {
Ok(landed) => landed,
// The node went with it; nothing is left to rename.
Err(e) if is_gone(e.as_ref()) => node.name.clone(),
@@ -740,6 +790,13 @@ impl Core {
}
};
self.db.delete_op(op.id)?;
// An op that wanted the name this node let go of waits no longer.
if let Some(from) = meta.and_then(|m| m.original_name)
&& from != landed
{
self.db.wake_ops_waiting_for(&from)?;
self.wake_drain();
}
info!(%uid, name = %landed, "pending rename landed");
Ok(())
}
@@ -749,7 +806,9 @@ impl Core {
/// offline; the node then lands under a conflict name instead.
fn drain_rename_in_place(
&self,
op: &PendingOp,
uid: &NodeUid,
parent: Option<&NodeUid>,
current: &str,
name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
@@ -759,8 +818,8 @@ impl Core {
match self.rename_remote(uid, name) {
Ok(()) => Ok(name.to_string()),
Err(e) if is_already_exists(&e) => {
if self.db.has_pending_trash_named(name)? {
return Err(held_by_queued_trash(name));
if self.name_is_held(op, parent, name)? {
return Err(held_by_queued_change(name));
}
let alt = conflict_name(name, now_secs());
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
@@ -786,6 +845,13 @@ impl Core {
pub(crate) fn drain_trash(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
let uid = parse_node_uid(&op.uid).ok_or("trash op has an unparseable uid")?;
let name = op.name.clone().unwrap_or_else(|| op.uid.clone());
// The trash a delete left of a create on the wire, which then failed:
// nothing was made, so nothing is trashed (`Db::delete_ops_for_uid`).
if is_local_uid_str(&op.uid) {
debug!(%uid, name, "withdrawn create never landed; trash op satisfied");
self.db.complete_trash_op(op.id, &uid)?;
return Ok(());
}
match self
.rt
.block_on(self.drive.trash_nodes(std::slice::from_ref(&uid)))
@@ -832,6 +898,22 @@ impl Core {
&self,
op: &PendingOp,
) -> Result<(), Box<dyn std::error::Error>> {
// Drive holds the name the create is sent with once it lands, whatever
// became of the file meanwhile, until the rename or trash queued
// behind it frees it (`Core::name_is_held`).
if let (Some(parent), Some(name)) = (op.parent_uid.as_deref(), op.name.as_deref())
&& let Some(parent) = parse_node_uid(parent)
{
self.creating
.lock()
.insert(op.id, (parent, name.to_string()));
}
let created = self.create_local_node(op);
self.creating.lock().remove(&op.id);
created
}
fn create_local_node(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
let local = parse_node_uid(&op.uid).ok_or("pending op has an unparseable uid")?;
let parent_str = op.parent_uid.as_deref().ok_or("create op has no parent")?;
// `run_pending_drain` will not offer an op whose parent is still a
@@ -863,9 +945,9 @@ impl Core {
adopted = true;
}
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref()))
&& self.db.has_pending_trash_named(&wanted)?
&& self.name_is_held(op, Some(&parent), &wanted)?
{
return Err(held_by_queued_trash(&wanted));
return Err(held_by_queued_change(&wanted));
}
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref())) {
name = conflict_name(&wanted, now_secs());
@@ -906,6 +988,12 @@ impl Core {
}
}
let real = real?;
// Retiring the op makes the ops behind it due: a child created in a new
// folder, a rename made while the create was on the wire. Drive answers
// a create before it lists the node, and those ops sent before then
// found no folder or dropped the rename as gone. Best effort:
// `adopt_real_uid` reads it back again and reports the failure.
let _ = self.read_back(&real);
// Retire the op before touching anything else: if we crash here the node
// exists remotely and the local placeholder is reconciled by the event
@@ -919,7 +1007,12 @@ impl Core {
sent: (parent_str, &wanted),
landed: (&home.to_string(), &name),
};
let newer = self.retire_create(op, uploaded, &landing, &local, &real)?;
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
CreateRetired::Withdrawn { trash } => {
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
}
retired => matches!(retired, CreateRetired::Newer { .. }),
};
// The uploaded blob is the new file's content, so it becomes the cached
// content, as for a revision. A partial write fills its gaps from it;
// without it the next write open was refused (`docs/BUGS.md` B123).
@@ -927,7 +1020,10 @@ impl Core {
.filter(|_| !newer)
.and(op.meta_json.as_deref())
.and_then(|json| serde_json::from_str(json).ok());
self.adopt_real_uid(&local, &real, |node| {
// The op is gone, so the create has landed whatever the read-back
// says. One that fails is finished before anything else is sent for
// the node (`docs/BUGS.md` B143).
if let Err(e) = self.adopt_real_uid(&local, &real, |node| {
if let (Some(blob), Some(meta)) = (uploaded, &landed)
&& node_size(node) == meta.len
&& keeps_landed_upload(self.cache.is_pinned(&real), meta.len, self.cache.budget())
@@ -936,7 +1032,10 @@ impl Core {
self.cache
.store_file(&real, node.modification_time, meta.len, Path::new(blob));
}
})?;
}) {
warn!(%local, %real, error = %e, "reading back a landed create failed; adopting it later");
self.unadopted.lock().insert(real.clone(), local.clone());
}
// The feed will report this create back to us; the tree already has it
// under its real uid, so that event is ours to ignore (`Core::self_changes`).
self.note_self_change(&real);
@@ -954,6 +1053,81 @@ impl Core {
Ok(())
}
/// Whether `name` is held on Drive by a change of ours that is to free it:
/// a queued trash, a queued rename away from it, or another create on the
/// wire that was sent with it in `parent`, which a rename or trash follows
/// (`Db::finish_create`). The op that wants the name waits for it rather
/// than land under a conflict name.
///
/// A trash is waited for as long as it takes. The rest only for
/// [`NAME_HOLD_ATTEMPTS`]: two files swapping names each hold the name the
/// other wants.
fn name_is_held(
&self,
op: &PendingOp,
parent: Option<&NodeUid>,
name: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
if self.db.has_pending_trash_named(name)? {
return Ok(true);
}
if op.attempts >= NAME_HOLD_ATTEMPTS {
return Ok(false);
}
let creating = self
.creating
.lock()
.iter()
.any(|(id, (p, n))| *id != op.id && Some(p) == parent && n == name);
Ok(creating || self.db.has_pending_move_from(name)?)
}
/// Trash the node a create made after the file was deleted, or replaced by
/// a rename, while its upload was on the wire (`docs/BUGS.md` B129).
///
/// `Db::finish_create` queued the trash, claimed, in the transaction that
/// found the create gone. It goes out now rather than behind the queue:
/// until it lands the node holds its name on Drive, and a file created
/// there again forks a conflict copy. A failure leaves it queued.
fn trash_withdrawn_create(
&self,
op: &PendingOp,
trash: i64,
local: &NodeUid,
real: &NodeUid,
name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
self.hidden.lock().insert(real.clone());
self.note_self_change(real);
let trash = PendingOp {
id: trash,
kind: OP_TRASH.to_string(),
uid: real.to_string(),
parent_uid: None,
name: Some(name.to_string()),
blob_path: None,
meta_json: None,
created_at: op.created_at,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
info!(%local, %real, name, "a create landed after its file was deleted; trashing it");
if let Err(e) = self.drain_trash(&trash) {
warn!(%real, name, error = %e, "trashing a withdrawn create failed; queued");
if let Err(e) = self.db.record_op_failure(
trash.id,
&e.to_string(),
now_millis() + DRAIN_BACKOFF_MIN.as_millis() as i64,
) {
error!(%real, error = %e, "recording a drain failure failed");
}
self.release_claim(&trash);
self.wake_drain();
}
Ok(())
}
/// The node already holding `name` under `parent`, if it is the one an
/// earlier attempt at this op made before its answer was lost.
///
@@ -1051,9 +1225,8 @@ impl Core {
Ok(uid)
}
/// Retire a landed create's op and move its queued state to the real uid.
/// True when a newer write attached to the create while it was uploading,
/// which is now queued as a revision of `real`.
/// Retire a landed create's op and move its queued state to the real uid,
/// unless the create was withdrawn while it was on the wire.
///
/// Runs under the `pending` lock, which `Core::enqueue_staged_write` also
/// holds from attaching a blob to a create until it records the pending
@@ -1068,17 +1241,22 @@ impl Core {
landing: &CreateLanding<'_>,
local: &NodeUid,
real: &NodeUid,
) -> Result<bool, Box<dyn std::error::Error>> {
let newer = {
) -> Result<CreateRetired, Box<dyn std::error::Error>> {
let retired = {
let mut pending = self.pending.lock();
let newer = self.db.finish_create(op.id, uploaded, landing, |json| {
let retired = self.db.finish_create(op.id, uploaded, landing, |json| {
let mut meta: StagedWrite = serde_json::from_str(json).ok()?;
meta.uid = landing.real.to_string();
serde_json::to_string(&meta).ok()
})?;
// A withdrawn create's placeholder is the delete's to clean up, and
// an open handle may still read its pending entry.
if let CreateRetired::Withdrawn { .. } = retired {
return Ok(retired);
}
pending.remove(local);
if let Some((blob, json)) = &newer {
let meta: StagedWrite = serde_json::from_str(json)?;
if let CreateRetired::Newer { blob, meta } = &retired {
let meta: StagedWrite = serde_json::from_str(meta)?;
pending.insert(
real.clone(),
PendingRevision {
@@ -1087,16 +1265,21 @@ impl Core {
},
);
}
newer.is_some()
retired
};
if !newer && let Some(blob) = uploaded {
if retired == CreateRetired::Landed
&& let Some(blob) = uploaded
{
self.cache_created(op, local, real, blob);
}
self.for_each_state(|st| {
if let Some(ino) = st.by_uid.remove(local) {
st.by_uid.insert(real.clone(), ino);
// The node too: the next write of it upserts the row by the
// node's uid, and would bring the placeholder row back.
if let Some(e) = st.entries.get_mut(&ino) {
e.uid = real.clone();
e.node.uid = real.clone();
}
// Its children still name the placeholder as their parent, and
// the next write of one would put it back on the row.
@@ -1114,7 +1297,7 @@ impl Core {
}
}
});
Ok(newer)
Ok(retired)
}
/// Cache a landed create's uploaded `blob` under `real`, keyed as the tree
@@ -1179,6 +1362,13 @@ impl Core {
// Before open handles are rebased onto the node, so one released in
// between finds what `adopt` keeps (`docs/BUGS.md` B123).
adopt(&node);
// What Drive holds is the base open handles and queued writes are
// rebased onto below. The tree shows a write queued over it, as a
// listing does: a write open sized from the landed node over a shorter
// queued blob left a gap that refused the next open (`docs/BUGS.md`
// B123).
let remote = node.clone();
self.stamp_pending_sizes(std::slice::from_mut(&mut node));
// Repoints queued children and node rows, and readdresses the placeholder row.
// A no-op after `retire_create`, which has already done both.
self.db
@@ -1233,10 +1423,22 @@ impl Core {
// write would land as a conflict copy of the empty file this create just
// made (`docs/BUGS.md` B113). Rebased before the restamp below, so a
// release that races this is caught there.
self.rebase_open_writes(real, &node);
//
// A release that took its base before the rebase and queues after the
// restamp is caught by neither. The revision this create sealed is
// ours, as one a revision upload sealed is, so the drain chains that
// write onto it instead of forking a conflict copy (B70 layer B).
if let Some(rev) = node_revision_id(&remote)
&& let Err(e) = self
.db
.set_own_sealed_rev(&real.to_string(), &rev, now_millis())
{
warn!(%real, error = %e, "persisting our sealed revision failed");
}
self.rebase_open_writes(real, &remote);
// A write carried over from the create was made against no revision at
// all; the one just created is what it now replaces.
self.rebaseline_pending(real, &node);
self.rebaseline_pending(real, &remote);
if node.is_folder() {
// It was recorded as listed while local (it was empty and had nothing
// to enumerate). That still holds: its queued children re-intern under
@@ -1828,16 +2030,25 @@ impl Core {
if self.rebaseline_pending(uid, &node) {
return Some(node);
}
// A rename queued behind the upload has not reached Drive yet, and
// until it lands the tree keeps the name and folder the user gave the
// file, as `adopt_real_uid` does. Taking Drive's put the file back
// under its old name.
let renaming = self
.db
.has_pending_op(&uid.to_string(), OP_RENAME)
.unwrap_or(false);
self.for_each_state(|st| {
let Some(parent) = st
.by_uid
.get(uid)
.and_then(|ino| st.entries.get(ino))
.map(|e| e.parent)
else {
let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino)) else {
return;
};
st.intern(parent, node.clone());
let parent = entry.parent;
let mut node = node.clone();
if renaming {
node.name = entry.node.name.clone();
node.parent_uid = entry.node.parent_uid.clone();
}
st.intern(parent, node);
});
Some(node)
}
@@ -1976,11 +2187,17 @@ fn park_verdict(created_at: i64, now: i64, open: bool) -> ParkVerdict {
ParkVerdict::Release
}
/// The retryable error for an op whose name a queued trash has yet to free.
fn held_by_queued_trash(name: &str) -> Box<dyn std::error::Error> {
format!("{name} is still held by a node whose trash is queued").into()
/// The retryable error for an op whose name a change of ours has yet to free
/// ([`Core::name_is_held`]).
fn held_by_queued_change(name: &str) -> Box<dyn std::error::Error> {
format!("{name} is still held by a node whose trash or rename is queued").into()
}
/// How many attempts an op waits for a rename or a create on the wire to free
/// the name it wants. Two files swapping names each wait for the other, so the
/// wait has to end; a conflict name ends it.
const NAME_HOLD_ATTEMPTS: i64 = 4;
/// How much older than its op a remote node may be and still be the one an
/// unanswered call made. The op is queued the moment the call gives up, so the
/// node is at most one call's deadline older; the rest is clock skew between
@@ -2076,6 +2293,11 @@ mod tests {
assert_eq!(authorities.len(), 2);
assert_eq!(authorities[0].to_string(), "v~node");
assert_eq!(authorities[1].to_string(), "v~parent");
let withdrawn = PendingOp {
uid: "local~never-landed".to_string(),
..pending(OP_TRASH)
};
assert!(pending_op_authorities(&withdrawn).unwrap().is_empty());
}
fn folder_node(link: &str, parent: Option<&str>) -> Node {
+31 -18
View File
@@ -1514,7 +1514,7 @@ impl ProtonFs {
//
// A create that set out online and lost the network on the way lands in
// the queue too, so a Wi-Fi drop costs the caller a wait, never an EIO.
let minted = if !transient && self.core.is_online() && !is_local_uid(&parent_uid) {
let minted = if !transient && self.core.sends_inline() && !is_local_uid(&parent_uid) {
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
reply.error(error);
return;
@@ -1620,7 +1620,7 @@ impl ProtonFs {
// queued — the folder becomes a placeholder that the drain turns into a
// real one (offline.md Phase 3b).
// Losing the network on the way queues it the same way.
let minted = if self.core.is_online() && !is_local_uid(&parent_uid) {
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
reply.error(error);
return;
@@ -1770,10 +1770,13 @@ impl ProtonFs {
self.core.cache.clear_scratch_durable(&h.path);
reply.ok();
}
(Some(h), false) => match self.core.queue_revision(&h) {
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
},
(Some(mut h), false) => {
self.core.follow_landed_create(&mut h);
match self.core.queue_revision(&h) {
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
}
}
(None, _) => reply.ok(),
}
}
@@ -1796,7 +1799,7 @@ impl ProtonFs {
reply.error(error);
return;
}
let (ino, uid) = match self.core.lookup_child(parent, name) {
let (ino, mut uid) = match self.core.lookup_child(parent, name) {
Ok(x) => x,
Err(e) => {
reply.error(e);
@@ -1921,6 +1924,8 @@ impl ProtonFs {
// to rename: the queued op *is* the node, so rewriting its target is the
// whole rename. Nothing reaches the API, which is why this works offline
// (offline.md Phase 3b).
// A create that lands meanwhile leaves no op to rewrite; the node is
// then renamed by the uid Drive gave it (docs/BUGS.md B142).
if is_local_uid(&uid) {
match self.core.db.rewrite_op_target(
&uid.to_string(),
@@ -1931,6 +1936,8 @@ impl ProtonFs {
self.core
.state()
.rename_in_place(ino, newparent, &new_parent_uid, newname);
// A create that failed on its old name is due again.
self.core.wake_drain();
// Finalize: a transient scratch file (its create parked, bytes
// held back) renamed to a finished name is the moment the
// completed file is meant to reach Drive. Un-park the create so
@@ -1949,23 +1956,29 @@ impl ProtonFs {
}
debug!(%uid, newname, "renamed a node whose create is still queued");
reply.ok();
return;
}
// The create drained underneath us, so the node has a real uid
// now and this handle's is stale. A retry resolves it.
Ok(false) => {
warn!(%uid, name, newname, "queued create vanished under a rename");
self.core
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
reply.error(Errno::EBUSY);
}
Ok(false) => match self.core.landed_row_uid(ino) {
Some(real) => {
debug!(local = %uid, %real, newname, "create landed under a rename");
uid = real;
}
None => {
warn!(%uid, name, newname, "queued create vanished under a rename");
self.core
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
reply.error(Errno::EBUSY);
return;
}
},
Err(e) => {
error!(%uid, error = %e, "rewriting a queued create's target failed");
self.core
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
reply.error(Errno::EIO);
return;
}
}
return;
}
// Offline, or into a folder that does not exist remotely yet: queue the
// rename rather than 404 or fail. Online moves into a real parent still
@@ -2020,7 +2033,7 @@ impl ProtonFs {
}
};
if rename_needs_queue(
self.core.is_online(),
self.core.sends_inline(),
is_local_uid(&new_parent_uid),
newparent != parent,
newname != name,
@@ -2133,7 +2146,7 @@ impl ProtonFs {
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
};
if !self.core.is_online() {
if !self.core.sends_inline() {
queue_it(reply);
return;
}
+68 -9
View File
@@ -569,6 +569,14 @@ struct Core {
/// Nodes removed through this daemon which a briefly stale remote listing
/// may still return. Uids are not reused; an explicit restore clears one.
hidden: Arc<Mutex<HashSet<NodeUid>>>,
/// The folder and name each create on the wire was sent with, by op id.
/// Drive holds that name until the create lands and a rename or trash
/// queued behind it frees it again.
creating: Arc<Mutex<HashMap<i64, (NodeUid, String)>>>,
/// Creates that landed but could not be read back, by the uid Drive gave
/// them, with the placeholder's. The drain adopts one before it sends
/// anything else for it.
unadopted: Arc<Mutex<HashMap<NodeUid, NodeUid>>>,
/// Nudges the drain workers: set true and notify to have them re-examine
/// the queue instead of waiting out their backoff.
drain_wake: Arc<(Mutex<bool>, Condvar)>,
@@ -624,6 +632,10 @@ struct Core {
/// ([`AppConfig::resolved_conflict_sweep`]); [`SweepMode::Off`] means the
/// sweep thread is never spawned at all. See `docs/BUGS.md` B71.
sweep_mode: SweepMode,
/// A mkdir, create, rename or delete through the mount is recorded and
/// queued, and the drain sends it; the syscall never waits for Drive. Off,
/// they go to Drive inline while online, as before 3.0.
local_first: bool,
/// Photos whose missing thumbnail is being generated right now. A tile that is
/// still on screen asks for its thumbnail again every few seconds, and each of
/// those downloads is a full-size photo — so an in-flight uid is never started
@@ -2485,6 +2497,14 @@ impl Core {
return Err(Errno::EIO);
}
};
// Drive shows none of what is still queued for the folder (B132).
// A shared folder's listing is published as Drive has it.
if !foreign {
match self.db.queued_children(&folder_uid) {
Ok((queued, gone)) => overlay_queued(&mut nodes, queued, &gone),
Err(e) => warn!(%folder_uid, error = %e, "db queued_children failed"),
}
}
// Same as the DB path above: the remote's size for a file with a write
// still queued is the pre-write one (B11).
self.stamp_pending_sizes(&mut nodes);
@@ -3102,6 +3122,33 @@ impl Core {
}
}
/// Point a handle closed while its file's create landed at the uid Drive
/// gave the node.
///
/// `Core::retire_create` moves the row to that uid before it repoints the
/// open handles. A handle closed in between still named the placeholder,
/// which no row has any more, and its write was refused as if access had
/// changed. The inode is the row's local id, which finds it either way.
pub(crate) fn follow_landed_create(&self, h: &mut WriteHandle) {
if !is_local_uid(&h.uid) {
return;
}
if let Some(real) = self.landed_row_uid(h.ino) {
debug!(local = %h.uid, %real, "write handle closed as its create landed");
h.uid = real;
}
}
/// The uid Drive gave the node at `ino`, once its create has landed, or
/// `None` while it is still queued. Read from the row, which has it before
/// the tree does.
pub(crate) fn landed_row_uid(&self, ino: u64) -> Option<NodeUid> {
state::ino_lid(ino)
.and_then(|lid| self.db.uid_of_lid(lid).ok().flatten())
.and_then(|uid| parse_node_uid(&uid))
.filter(|uid| !is_local_uid(uid))
}
/// Accept a released write handle's bytes and queue their upload
/// (offline.md Phase 3).
///
@@ -4758,9 +4805,9 @@ impl Core {
debug!(%uid, name, "replaced a node whose create was still queued");
return Ok(());
}
// Offline, or the network goes away under the call: queue the trash.
// The rename that follows queues as well, behind it.
if !self.is_online() {
// Local-first or offline, or the network goes away under the call:
// queue the trash. The rename that follows queues as well, behind it.
if !self.sends_inline() {
return self.queue_trash(uid, name);
}
self.require_uid_writable(uid)?;
@@ -6223,17 +6270,17 @@ fn copy_pending_for_truncate(pending: &PendingRevision, destination: &File) -> s
}
/// Whether a rename must be represented as a durable desired end state.
/// Offline operations and operations targeting a not-yet-uploaded directory
/// cannot use the API immediately. Online remote-to-remote operations stay
/// synchronous so a successful kernel reply means the remote namespace has
/// already reached the same end state.
/// Local-first, offline, or into a not-yet-uploaded directory, the API is not
/// called in the syscall. Only remote-to-remote renames sent inline
/// ([`Core::sends_inline`]) stay synchronous, so a successful kernel reply means
/// the remote namespace has already reached the same end state.
fn rename_needs_queue(
online: bool,
inline: bool,
destination_is_local: bool,
_parent_changed: bool,
_name_changed: bool,
) -> bool {
!online || destination_is_local
!inline || destination_is_local
}
/// Convert one kernel pathname component into the UTF-8 name accepted by Drive.
@@ -6254,6 +6301,18 @@ fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet<NodeUid>) ->
!node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid)
}
/// Lay what is queued for a folder over Drive's listing of it, from
/// `Db::queued_children`: a node a queued rename moved away is dropped, and a
/// node a queued create, mkdir or rename put there is listed as the DB has it.
fn overlay_queued(nodes: &mut Vec<Node>, queued: Vec<Node>, gone: &HashSet<String>) {
let here: HashSet<String> = queued.iter().map(|node| node.uid.to_string()).collect();
nodes.retain(|node| {
let uid = node.uid.to_string();
!gone.contains(&uid) && !here.contains(&uid)
});
nodes.extend(queued);
}
/// Bytes rendered with a binary unit and one decimal place (e.g. `"1.2 GB"`),
/// for the activity log. Uses 1024-based steps but the shorter SI labels.
fn human_bytes(bytes: u64) -> String {
+9
View File
@@ -113,6 +113,15 @@ impl Core {
self.online.load(Ordering::Relaxed)
}
/// Whether a namespace change through the mount goes to Drive inside the
/// syscall. Only with [`AppConfig::local_first`] off, and only online;
/// otherwise it is queued and the drain sends it.
///
/// [`AppConfig::local_first`]: pdfs_core::config::AppConfig::local_first
pub(crate) fn sends_inline(&self) -> bool {
!self.local_first && self.is_online()
}
/// Run a remote call a caller is waiting on, giving up after
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
/// FUSE worker has no reactor of its own.
+7
View File
@@ -244,6 +244,9 @@ pub struct MountOptions {
/// Upload and download caps in bytes per second (`0` = no cap).
pub upload_limit: u64,
pub download_limit: u64,
/// Namespace changes through the mount are queued, not sent in the
/// syscall. See [`pdfs_core::config::AppConfig::local_first`].
pub local_first: bool,
}
/// Spawn one FUSE session rooted at an arbitrary remote node.
@@ -385,6 +388,7 @@ pub(crate) fn mount_with(
sweep_mode,
upload_limit,
download_limit,
local_first,
} = options;
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
// so the age it reports is the daemon's own.
@@ -429,6 +433,8 @@ pub(crate) fn mount_with(
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())),
creating: Arc::new(Mutex::new(HashMap::new())),
unadopted: Arc::new(Mutex::new(HashMap::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
upload_times: Arc::new(Mutex::new(HashMap::new())),
@@ -442,6 +448,7 @@ pub(crate) fn mount_with(
trash_progress: Arc::new(tokio::sync::Notify::new()),
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
sweep_mode,
local_first,
self_changes: Arc::new(Mutex::new(HashMap::new())),
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
+49 -4
View File
@@ -91,6 +91,7 @@ impl Daemon {
sweep_mode: AppConfig::default().resolved_conflict_sweep(),
upload_limit: 0,
download_limit: 0,
local_first: AppConfig::default().local_first.unwrap_or(true),
},
Host::Simulation(stopped),
)
@@ -412,10 +413,54 @@ mod tests {
std::fs::rename(mount.join("x/f.txt"), mount.join("z/d")).unwrap();
assert_eq!(std::fs::read(mount.join("z/d")).unwrap(), b"moved");
let tree = drive.tree();
assert_eq!(tree.get("z/d").cloned(), file(b"moved"));
assert_eq!(tree.get("x/d").cloned(), file(b"sibling"));
assert!(!tree.contains_key("x/f.txt"));
// The rename is recorded locally and sent from the queue.
assert!(
wait_until(Duration::from_secs(30), || {
let tree = drive.tree();
tree.get("z/d").cloned() == file(b"moved")
&& tree.get("x/d").cloned() == file(b"sibling")
&& !tree.contains_key("x/f.txt")
}),
"{:?}",
drive.tree().keys().collect::<Vec<_>>()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_write_over_a_create_whose_read_back_failed_is_not_a_conflict() {
// The create landed and the link went down before it was read back.
// The next write was based on the file as it was made locally, and
// landed as a conflict copy of it (B143).
let drive = FakeDrive::new();
let dir = scratch("unadopted");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
let path = daemon.mountpoint.join("f.txt");
// Drive stamps a create with the second it arrives, so it has to land
// a second after the file was made here for the two to differ.
daemon.client.set_online(false);
std::fs::write(&path, b"first").unwrap();
std::thread::sleep(Duration::from_millis(1100));
daemon.client.drop_link_after_next_create();
daemon.client.set_online(true);
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().get("f.txt").cloned() == file(b"first")
}));
write_at(&path, 5, b" and second").unwrap();
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("f.txt").cloned() == file(b"first and second")
}),
"{:?}",
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
+24 -10
View File
@@ -330,6 +330,7 @@ impl FakeDrive {
online: AtomicBool::new(true),
requests: AtomicU64::new(0),
hold: Mutex::new(None),
drop_after_create: AtomicBool::new(false),
}))
}
@@ -577,6 +578,8 @@ struct ClientInner {
requests: AtomicU64,
/// The read [`FakeClient::hold_next_read`] is waiting for.
hold: Mutex<Option<Arc<HoldState>>>,
/// Set by [`FakeClient::drop_link_after_next_create`].
drop_after_create: AtomicBool,
}
struct HoldState {
@@ -616,6 +619,12 @@ impl FakeClient {
*self.0.faults.lock() = faults;
}
/// Take the link down as soon as the next file create has been answered,
/// so whatever the daemon asks next goes unanswered.
pub(crate) fn drop_link_after_next_create(&self) {
self.0.drop_after_create.store(true, Ordering::SeqCst);
}
/// Hold the next read of `uid` alone, before it reaches Drive, until the
/// answer is dropped. Only one read is held at a time.
pub(crate) fn hold_next_read(&self, uid: &NodeUid) -> Held {
@@ -1038,16 +1047,21 @@ impl DriveApi for FakeClient {
_aead: bool,
) -> Result<NodeUid> {
let content = read_all(reader)?;
self.request(true, |server| {
server.new_file(
parent_uid,
name,
media_type,
content,
last_modification_time,
)
})
.await
let made = self
.request(true, |server| {
server.new_file(
parent_uid,
name,
media_type,
content,
last_modification_time,
)
})
.await;
if made.is_ok() && self.0.drop_after_create.swap(false, Ordering::SeqCst) {
self.set_online(false);
}
made
}
async fn upload_file_replacing_draft_from_dyn(
+13
View File
@@ -212,6 +212,12 @@ pub(crate) fn lid_ino(lid: i64) -> u64 {
lid.unsigned_abs() + crate::ROOT_INO
}
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
/// `None` for the root and a fallback inode.
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
(ino > crate::ROOT_INO && ino < FALLBACK_INO).then(|| (ino - crate::ROOT_INO) as i64)
}
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
/// `Filesystem` trait through `&self`.
///
@@ -1325,6 +1331,13 @@ mod tests {
use proton_drive_rs::ShareMembership;
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
#[test]
fn an_inode_gives_back_the_lid_it_was_made_from() {
assert_eq!(ino_lid(lid_ino(7)), Some(7));
assert_eq!(ino_lid(crate::ROOT_INO), None);
assert_eq!(ino_lid(FALLBACK_INO + 3), None);
}
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}