fix(fuse,core): fix B114 to B120 from the account runs

- B114: `pdfs rm`, `pdfs rename` and `pdfs move` tell each kernel
  session that the old name is gone, so it stops resolving at once
  instead of for up to 30 seconds (`Core::forget_everywhere`,
  `Core::relocate_everywhere`).
- B115: `pdfs ls` and the app's file browser wait up to 10 seconds for
  the listing's size fetch, as `stat` does, so they show a file's real
  size instead of its encrypted one.
- B116: trashing a folder also drops the creates queued in its
  subfolders. A parked one stayed queued with its bytes, and a due one
  would have landed in the root.
- B117: `pdfs move` and `pdfs rename` move the node in each mount
  instead of forgetting it, so its queued upload keeps its path in
  `pdfs sync queue` and is not put off once after the resume.
- B118: when a create or mkdir reads back nothing, the mount asks again
  and then queues it as it does offline, instead of failing with ENOENT
  for something Drive has made.
- B119: a landed upload keeps its staged bytes as the file's cached
  content, unless it is larger than a sixteenth of the cache, so
  reading a file just written does not need the network.
- B120: a landed folder's row passes to its real uid in the same
  transaction, so a file queued inside it is not put off for five
  seconds.

docs/BUGS.md records B117 to B120. The account run of 2026-10-03
verified B114 to B117 and B119 in My files.
This commit is contained in:
nvrl committed 2026-10-03 02:10:23 +02:00
1 parent 625776081b
commit 37c04f1e69
9 files changed
+791 -105

No files matched your search

+44
View File
@@ -599,6 +599,50 @@ struct PriorRow {
trashed: bool,
}
/// Hand a drained placeholder's row, and the rows below it, to the real uid it
/// landed as.
///
/// Dropping the placeholder row and waiting for the server's copy of the real
/// node left a window with no row for either uid. A queued child drained in
/// that window found no authority for its parent, re-read it over the network
/// and was deferred five seconds behind a folder that had just landed
/// (`docs/BUGS.md` B120). The placeholder's own node, readdressed, stands in
/// until the server's copy is upserted over it. A row the real uid already
/// has is newer than the placeholder and is kept.
pub(super) fn adopt_placeholder_row_tx(
tx: &Transaction<'_>,
local: &str,
real: &str,
) -> Result<()> {
tx.execute(
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
let row: Option<(i64, Option<String>)> = tx
.query_row(
"SELECT rowid, node_json FROM nodes WHERE uid = ?1",
params![local],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
let Some((rowid, json)) = row else {
return Ok(());
};
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
let known: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
params![real],
|row| row.get(0),
)?;
let (false, Some(json), Some(real)) = (known, json, parse_node_uid(real)) else {
return Ok(());
};
let mut node: Node = serde_json::from_str(&json)?;
node.uid = real;
upsert_node_tx(tx, &node)
}
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
+24 -19
View File
@@ -6,6 +6,7 @@ use rusqlite::{OptionalExtension, params};
use serde::{Deserialize, Serialize};
use super::Db;
use super::nodes::adopt_placeholder_row_tx;
use crate::Result;
/// The `kind` of a [`PendingOp`] that uploads a staged file as a new revision.
@@ -75,22 +76,33 @@ pub fn op_supersedes(kind: &str) -> bool {
/// a removed folder: the upload outlived the folder, failed forever against a
/// trashed node, and kept its blob on disk (`docs/BUGS.md` B102).
///
/// Only revisions are taken from the node tree. 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.
/// "Queued beneath it" starts from every folder the node tree places there,
/// not only from `uid`. A create in a subfolder that already exists on Drive
/// names that subfolder as its parent, so following the queue from `uid` alone
/// missed it: a parked one kept its blob for good, and a due one would have
/// been made in the root once its folder turned out trashed (`docs/BUGS.md`
/// B116).
///
/// 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.
fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<String>> {
const DOOMED: &str = "
WITH RECURSIVE
queued(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p JOIN queued q ON p.parent_uid = q.uid
),
below(uid) AS (
SELECT ?1
UNION
SELECT n.uid FROM nodes n JOIN below b ON n.parent_uid = b.uid
),
queued(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p
WHERE p.kind IN ('create', 'mkdir')
AND p.parent_uid IN (SELECT uid FROM below)
UNION
SELECT p.uid FROM pending_op p JOIN queued q ON p.parent_uid = q.uid
),
doomed(id) AS (
SELECT id FROM pending_op
WHERE uid IN (SELECT uid FROM queued)
@@ -424,11 +436,7 @@ impl Db {
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute(
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
adopt_placeholder_row_tx(&tx, local, real)?;
tx.commit()?;
Ok(newer)
}
@@ -547,7 +555,8 @@ impl Db {
/// Rewrite every queued op that points at a placeholder parent, once that
/// parent has drained and has a real uid. Also moves the node rows whose
/// parent column still names the placeholder, so listings keep resolving.
/// parent column still names the placeholder, so listings keep resolving,
/// and readdresses the placeholder's own row.
pub fn remap_local_uid(&self, local: &str, real: &str) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
@@ -555,11 +564,7 @@ impl Db {
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute(
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
adopt_placeholder_row_tx(&tx, local, real)?;
tx.commit()?;
Ok(())
}
+154
View File
@@ -2579,6 +2579,74 @@ fn trashing_a_folder_drops_the_queued_revisions_of_files_below_it() {
);
}
/// A create in a subfolder that already exists on Drive names that subfolder,
/// not the folder being trashed, as its parent. It has to go with the folder
/// all the same, along with whatever is queued inside it, or a parked one
/// keeps its bytes for good and a due one is made in the root (B116).
#[test]
fn trashing_a_folder_drops_the_queued_creates_in_its_subfolders() {
let db = Db::open_in_memory().unwrap();
db.upsert_nodes(&[
folder("root", None, "My Files"),
folder("doomed", Some("root"), "doomed"),
folder("sub", Some("doomed"), "sub"),
folder("other", Some("root"), "other"),
])
.unwrap();
let op = |kind: &str, node: &str, parent: &str, blob: Option<&str>| PendingOp {
id: 0,
kind: kind.to_string(),
uid: node.to_string(),
parent_uid: Some(parent.to_string()),
name: Some(format!("{node}.bin")),
blob_path: blob.map(str::to_string),
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
let sub = uid("sub").to_string();
db.enqueue_op(&op(OP_CREATE, "local~deep", &sub, Some("/staging/deep")))
.unwrap();
db.enqueue_op(&op(OP_MKDIR, "local~dir", &sub, None))
.unwrap();
db.enqueue_op(&op(
OP_CREATE,
"local~deeper",
"local~dir",
Some("/staging/deeper"),
))
.unwrap();
db.enqueue_op(&op(
OP_CREATE,
"local~kept",
&uid("other").to_string(),
Some("/staging/kept"),
))
.unwrap();
let doomed = uid("doomed").to_string();
let (_, mut blobs) = db.replace_ops_with_trash(&doomed, "doomed", 2).unwrap();
blobs.sort();
assert_eq!(blobs, vec!["/staging/deep", "/staging/deeper"]);
let mut left: Vec<(String, String)> = db
.pending_ops()
.unwrap()
.into_iter()
.map(|op| (op.kind, op.uid))
.collect();
left.sort();
assert_eq!(
left,
vec![
(OP_CREATE.to_string(), "local~kept".to_string()),
(OP_TRASH.to_string(), doomed),
]
);
}
/// Deleting a folder that was created offline must take the ops queued
/// underneath it with it: they name a placeholder parent that will now never
/// become real, so nothing could ever drain them and nothing is left to
@@ -2902,6 +2970,92 @@ fn draining_a_folder_repoints_its_queued_children() {
assert_eq!(ops[0].parent_uid.as_deref(), Some(real_dir.as_str()));
}
/// B120: a child queued inside a folder that has just landed is checked
/// against the folder's real uid before the server's copy of it arrives.
#[test]
fn a_landed_folder_keeps_a_row_until_the_server_copy_replaces_it() {
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("dir", Some("root"), "New folder");
placeholder.uid = local.clone();
db.upsert_node(&placeholder).unwrap();
let mut child = file("child", "root", "inside.txt", 1);
child.parent_uid = Some(local.clone());
db.upsert_node(&child).unwrap();
let (id, _) = db
.enqueue_op(&PendingOp {
id: 0,
kind: OP_MKDIR.to_string(),
uid: local.to_string(),
parent_uid: Some(uid("root").to_string()),
name: Some("New folder".to_string()),
blob_path: None,
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
db.finish_create(
id,
None,
&local.to_string(),
&uid("real").to_string(),
|_| None,
)
.unwrap();
assert!(db.node_by_uid(&local.to_string()).unwrap().is_none());
let landed = db
.node_by_uid(&uid("real").to_string())
.unwrap()
.expect("the landed folder has a row before the server's copy is read");
assert_eq!(landed.uid, uid("real"));
assert_eq!(landed.name, "New folder");
assert_eq!(
db.effective_node_access(&uid("child")).unwrap(),
Some(crate::Access::Owner),
"the child's authority resolves without a network re-read"
);
let names: Vec<_> = db
.known_children(&uid("real"))
.unwrap()
.into_iter()
.map(|n| n.name)
.collect();
assert_eq!(names, ["inside.txt"]);
let found = db.search("inside", 10).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].path, "New folder/inside.txt");
}
#[test]
fn a_landed_placeholder_never_overwrites_the_server_copy() {
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("dir", Some("root"), "New folder");
placeholder.uid = local.clone();
db.upsert_node(&placeholder).unwrap();
// The event stream reported the folder before the drain got round to it.
db.upsert_node(&folder("real", Some("root"), "New folder (1)"))
.unwrap();
db.remap_local_uid(&local.to_string(), &uid("real").to_string())
.unwrap();
assert!(db.node_by_uid(&local.to_string()).unwrap().is_none());
assert_eq!(
db.node_by_uid(&uid("real").to_string())
.unwrap()
.map(|n| n.name),
Some("New folder (1)".to_string())
);
}
#[test]
fn a_failed_op_stays_queued_with_backoff() {
let db = Db::open_in_memory().unwrap();