mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(core,fuse,cli): harden writes, trash and cleanup found by account acceptance runs
The FUSE acceptance suite now runs on the signed-in account by default and cleans up after itself; its first live runs found three daemon bugs. - B101: a pwrite past EOF now authors the hole it leaves, so truncated bytes no longer come back from the base. A write attached to a create while that create uploads becomes a revision of the real uid instead of being dropped. - B102: removing a folder drops the queued revisions of files below it, found through the node tree, with their blobs and in-flight uploads. `pdfs rm` now drops queued ops too. - B103: each server call of a trash refresh times out after 120 s, so one hung call can no longer freeze the trash listing until restart. Acceptance suite: account mode with a manifest, lock and reaper; cleanup by uid; queued ops naming a test root count as leftovers; new edge-case, throughput and B100/B101 cases; the wide-directory case is split from the deep tree and gets a doubled budget. Adds `pdfs trash --json`.
This commit is contained in:
1 parent
84c9ade8e3
commit
7b67db5ced
13 files changed
+1785
-126
No files matched your search
@@ -30,7 +30,7 @@ struct Cli {
|
||||
/// Emit machine-readable JSON instead of formatted text.
|
||||
///
|
||||
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `trash`, `cache inspect`). Commands that
|
||||
/// perform an action keep their human output — a script that needs to know
|
||||
/// whether one succeeded has the exit code.
|
||||
#[arg(long, global = true)]
|
||||
@@ -2551,7 +2551,11 @@ fn cmd_diagnostics() -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_trash() -> Result<()> {
|
||||
match control_request(CtlRequest::ListTrash)? {
|
||||
let response = control_request(CtlRequest::ListTrash)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"),
|
||||
CtlResponse::Entries { entries } => {
|
||||
for e in entries {
|
||||
|
||||
+111
-39
@@ -66,6 +66,52 @@ pub fn op_supersedes(kind: &str) -> bool {
|
||||
matches!(kind, OP_REVISION | OP_RENAME | OP_TRASH)
|
||||
}
|
||||
|
||||
/// Delete the ops that removing `uid` takes with it, returning the staged blobs
|
||||
/// they held.
|
||||
///
|
||||
/// That is every op for `uid` or for anything queued beneath it, plus the
|
||||
/// revisions of files the node tree places beneath it. A revision op carries
|
||||
/// no `parent_uid`, so the queue alone cannot tell that its file sits inside
|
||||
/// 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.
|
||||
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
|
||||
),
|
||||
doomed(id) AS (
|
||||
SELECT id FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM queued)
|
||||
OR (kind = 'revision' AND uid IN (SELECT uid FROM below))
|
||||
)";
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{DOOMED}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE id IN (SELECT id FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{DOOMED} DELETE FROM pending_op WHERE id IN (SELECT id FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
Ok(blobs)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -252,27 +298,9 @@ impl Db {
|
||||
name: &str,
|
||||
created_at: i64,
|
||||
) -> Result<(i64, Vec<String>)> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||
tx.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
|
||||
@@ -326,6 +354,67 @@ impl Db {
|
||||
Ok(Some(AttachedBlob { id, superseded }))
|
||||
}
|
||||
|
||||
/// Retire a queued create that has just landed as `real`, and repoint
|
||||
/// everything that named its placeholder, in one transaction.
|
||||
///
|
||||
/// The drain uploads the blob the create held when it was picked up. A
|
||||
/// write that closes while that upload is on the wire attaches a newer blob
|
||||
/// to the *same row* (`attach_blob_to_create`), so deleting the row by id
|
||||
/// threw that write away: the server kept the older bytes and nothing was
|
||||
/// left queued to replace them. When the row no longer holds `uploaded`, it
|
||||
/// becomes a revision of `real` instead, with its sidecar passed through
|
||||
/// `rewrite` so it names the real node. Returns that blob and sidecar, or
|
||||
/// `None` when the create is simply done.
|
||||
pub fn finish_create(
|
||||
&self,
|
||||
id: i64,
|
||||
uploaded: Option<&str>,
|
||||
local: &str,
|
||||
real: &str,
|
||||
rewrite: impl FnOnce(&str) -> Option<String>,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let row: Option<(Option<String>, Option<String>)> = tx
|
||||
.query_row(
|
||||
"SELECT blob_path, meta_json FROM pending_op WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let newer = match row {
|
||||
Some((Some(blob), Some(meta))) if uploaded != Some(blob.as_str()) => {
|
||||
rewrite(&meta).map(|meta| (blob, meta))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match &newer {
|
||||
Some((_, meta)) => {
|
||||
tx.execute(
|
||||
"UPDATE pending_op
|
||||
SET kind = ?2, uid = ?3, parent_uid = NULL, name = NULL, meta_json = ?4,
|
||||
attempts = 0, last_error = NULL, next_attempt_at = 0
|
||||
WHERE id = ?1",
|
||||
params![id, OP_REVISION, real, meta],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![id])?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"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])?;
|
||||
tx.commit()?;
|
||||
Ok(newer)
|
||||
}
|
||||
|
||||
/// Replace the sidecar of a queued op, for a baseline that has moved under
|
||||
/// it.
|
||||
///
|
||||
@@ -428,29 +517,12 @@ impl Db {
|
||||
/// and in the user's pending count, for the life of the database.
|
||||
///
|
||||
/// Only `create`/`mkdir` ops carry a `parent_uid`, so for a file this
|
||||
/// recursion finds nothing and costs one query.
|
||||
/// recursion finds nothing and costs one query. Revisions of files the
|
||||
/// node tree places beneath `uid` go too; see [`drop_doomed_ops`].
|
||||
pub fn delete_ops_for_uid(&self, uid: &str) -> Result<Vec<String>> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||
tx.commit()?;
|
||||
Ok(blobs)
|
||||
}
|
||||
|
||||
@@ -2498,6 +2498,71 @@ fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A revision op names no parent, so the queue alone cannot see that its file
|
||||
/// sits inside a folder being removed. The node tree can: trashing the folder
|
||||
/// has to take the uploads of the files below it along, or they fail forever
|
||||
/// against a trashed node and keep their bytes on disk (B102). A rename that
|
||||
/// moved a node into the folder still has to land.
|
||||
#[test]
|
||||
fn trashing_a_folder_drops_the_queued_revisions_of_files_below_it() {
|
||||
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"),
|
||||
file("deep", "sub", "deep.bin", 1),
|
||||
file("moved", "doomed", "moved.txt", 1),
|
||||
file("kept", "root", "kept.bin", 1),
|
||||
])
|
||||
.unwrap();
|
||||
let op = |kind: &str, link: &str, blob: Option<&str>| PendingOp {
|
||||
id: 0,
|
||||
kind: kind.to_string(),
|
||||
uid: uid(link).to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: blob.map(str::to_string),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep")))
|
||||
.unwrap();
|
||||
db.enqueue_op(&op(OP_RENAME, "moved", None)).unwrap();
|
||||
db.enqueue_op(&op(OP_REVISION, "kept", Some("/staging/kept")))
|
||||
.unwrap();
|
||||
|
||||
let doomed = uid("doomed").to_string();
|
||||
let (_, blobs) = db.replace_ops_with_trash(&doomed, "doomed", 2).unwrap();
|
||||
assert_eq!(blobs, vec!["/staging/deep"]);
|
||||
|
||||
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_RENAME.to_string(), uid("moved").to_string()),
|
||||
(OP_REVISION.to_string(), uid("kept").to_string()),
|
||||
(OP_TRASH.to_string(), doomed.clone()),
|
||||
]
|
||||
);
|
||||
|
||||
// Deleting outright follows the same tree.
|
||||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep2")))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.delete_ops_for_uid(&uid("sub").to_string()).unwrap(),
|
||||
vec!["/staging/deep2"]
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -2699,6 +2764,99 @@ fn attaching_to_an_already_drained_create_reports_it_is_gone() {
|
||||
assert!(out.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_attached_while_its_create_uploads_becomes_a_revision() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let local = "local~abc";
|
||||
let real = uid("real").to_string();
|
||||
let create = |blob: Option<&str>| PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(uid("parent").to_string()),
|
||||
name: Some("new.txt".to_string()),
|
||||
blob_path: blob.map(str::to_string),
|
||||
meta_json: blob.map(|_| "{\"uid\":\"local~abc\"}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
let (id, _) = db.enqueue_op(&create(None)).unwrap();
|
||||
// The drain picked the create up with no blob; the file's first write
|
||||
// closes while the empty upload is on the wire.
|
||||
db.attach_blob_to_create(local, "/staging/written", "{\"uid\":\"local~abc\"}")
|
||||
.unwrap()
|
||||
.expect("create is still queued");
|
||||
|
||||
let newer = db
|
||||
.finish_create(id, None, local, &real, |json| {
|
||||
Some(json.replace("local~abc", "vol~real"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
newer,
|
||||
Some((
|
||||
"/staging/written".to_string(),
|
||||
"{\"uid\":\"vol~real\"}".to_string()
|
||||
))
|
||||
);
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1, "the write is still queued");
|
||||
assert_eq!(ops[0].kind, OP_REVISION);
|
||||
assert_eq!(ops[0].uid, real);
|
||||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/written"));
|
||||
assert_eq!(ops[0].parent_uid, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_create_whose_blob_landed_is_retired() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let local = "local~abc";
|
||||
let real = uid("real").to_string();
|
||||
let (id, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(uid("parent").to_string()),
|
||||
name: Some("new.txt".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.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: "local~child".to_string(),
|
||||
parent_uid: Some(local.to_string()),
|
||||
name: Some("inside.txt".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 2,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let newer = db
|
||||
.finish_create(id, Some("/staging/sent"), local, &real, |_| {
|
||||
panic!("an unchanged create has no sidecar to rewrite")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(newer, None);
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draining_a_folder_repoints_its_queued_children() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -41,7 +41,7 @@ use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeMoveItem};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::state::Intervals;
|
||||
use super::state::{Intervals, PendingRevision};
|
||||
use super::transfers::CountingReader;
|
||||
use super::{
|
||||
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, DRAIN_REVISION_DEBOUNCE,
|
||||
@@ -886,15 +886,20 @@ impl Core {
|
||||
// Retire the op before touching anything else: if we crash here the node
|
||||
// exists remotely and the local placeholder is reconciled by the event
|
||||
// sync, whereas a surviving op would create the file a second time.
|
||||
self.db.delete_op(op.id)?;
|
||||
let newer = self.retire_create(op, &local, &real)?;
|
||||
self.adopt_real_uid(&local, &real)?;
|
||||
// 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);
|
||||
// The uploaded blob is done with either way: a write that replaced it
|
||||
// while it was on the wire already discarded it when it attached.
|
||||
if let Some(blob) = op.blob_path.as_deref() {
|
||||
self.cache.discard_staged(Path::new(blob));
|
||||
}
|
||||
self.pending.lock().remove(&local);
|
||||
if newer {
|
||||
info!(%local, %real, name, "a write landed during the create; queued it as a revision");
|
||||
self.wake_drain();
|
||||
}
|
||||
self.log_activity(ActivityKind::Upload, &name, "created", true);
|
||||
info!(%local, %real, name, kind = %op.kind, "pending create landed");
|
||||
Ok(())
|
||||
@@ -963,6 +968,65 @@ 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`.
|
||||
///
|
||||
/// Runs under the `pending` lock, which `Core::enqueue_staged_write` also
|
||||
/// holds from attaching a blob to a create until it records the pending
|
||||
/// entry. So a write either attached before this ran and is carried over,
|
||||
/// or finds the create gone and queues against the real uid itself. The
|
||||
/// open write handles are repointed right after, for the same reason:
|
||||
/// a handle released later must not queue against the placeholder.
|
||||
fn retire_create(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
local: &NodeUid,
|
||||
real: &NodeUid,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let real_str = real.to_string();
|
||||
let newer = {
|
||||
let mut pending = self.pending.lock();
|
||||
let newer = self.db.finish_create(
|
||||
op.id,
|
||||
op.blob_path.as_deref(),
|
||||
&local.to_string(),
|
||||
&real_str,
|
||||
|json| {
|
||||
let mut meta: StagedWrite = serde_json::from_str(json).ok()?;
|
||||
meta.uid = real_str.clone();
|
||||
serde_json::to_string(&meta).ok()
|
||||
},
|
||||
)?;
|
||||
pending.remove(local);
|
||||
if let Some((blob, json)) = &newer {
|
||||
let meta: StagedWrite = serde_json::from_str(json)?;
|
||||
pending.insert(
|
||||
real.clone(),
|
||||
PendingRevision {
|
||||
path: PathBuf::from(blob),
|
||||
meta,
|
||||
},
|
||||
);
|
||||
}
|
||||
newer.is_some()
|
||||
};
|
||||
self.for_each_state(|st| {
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
}
|
||||
}
|
||||
for aw in st.active_writes.values_mut() {
|
||||
if aw.uid == *local {
|
||||
aw.uid = real.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(newer)
|
||||
}
|
||||
|
||||
/// Swap a placeholder uid for the real one across everything that keyed off
|
||||
/// it: queued children, the DB, the in-memory tree, and the caches.
|
||||
///
|
||||
@@ -985,14 +1049,17 @@ impl Core {
|
||||
.fetch_node(real)
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
// Repoints queued children and node rows, and drops the placeholder row.
|
||||
// A no-op after `retire_create`, which has already done both.
|
||||
self.db
|
||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||
|
||||
self.for_each_state(|st| {
|
||||
let Some(ino) = st.by_uid.remove(local) else {
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
}
|
||||
let Some(&ino) = st.by_uid.get(real) else {
|
||||
return;
|
||||
};
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
e.node = node.clone();
|
||||
@@ -1024,6 +1091,9 @@ impl Core {
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||
}
|
||||
// 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);
|
||||
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
|
||||
|
||||
@@ -442,10 +442,7 @@ impl Filesystem for ProtonFs {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
};
|
||||
let end = offset + data.len() as u64;
|
||||
aw.written.add(offset, end);
|
||||
aw.len = aw.len.max(end);
|
||||
aw.dirty = true;
|
||||
aw.record_write(offset, offset + data.len() as u64);
|
||||
let len = aw.len;
|
||||
st.set_size(ino.0, len);
|
||||
len
|
||||
|
||||
+127
-39
@@ -168,6 +168,10 @@ const DRAIN_BACKOFF_MAX: Duration = Duration::from_secs(300);
|
||||
/// directly on a new write or a reconnect, so this only bounds how late a
|
||||
/// backoff can fire.
|
||||
const DRAIN_IDLE_POLL: Duration = Duration::from_secs(30);
|
||||
/// How long a write whose create just landed waits for the drain to repoint
|
||||
/// its inode (`Core::landed_uid`): 200 polls of 10 ms, two seconds in all.
|
||||
const LANDED_UID_POLLS: u32 = 200;
|
||||
const LANDED_UID_POLL: Duration = Duration::from_millis(10);
|
||||
/// Grace period before a queued revision becomes eligible for draining.
|
||||
///
|
||||
/// Tools like aria2c preallocate a file (truncate to target size) and then write
|
||||
@@ -261,6 +265,15 @@ const TRASH_MATERIALIZE_CHUNK: usize = 150;
|
||||
/// outlives that timeout is a hang from the user's side: the client gives up,
|
||||
/// the user asks again, and the daemon accumulates another refresh.
|
||||
const TRASH_FIRST_WAIT: Duration = Duration::from_secs(20);
|
||||
/// How long one server call of a trash refresh may take before the refresh
|
||||
/// gives up.
|
||||
///
|
||||
/// Refreshes are single-flight, so a call that never returns wedges the trash
|
||||
/// listing for the life of the daemon: every later request joins the stuck
|
||||
/// refresh, times out, and answers with the listing from before it. That hid
|
||||
/// a just-trashed folder from a permanent delete for as long as the daemon ran
|
||||
/// (`docs/BUGS.md` B103). One call is one request, so this is generous.
|
||||
const TRASH_CALL_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// `sync_state` keys for the freshness stamps of the two persisted listings, and
|
||||
/// for whether the account has a photos volume at all (so an account without one
|
||||
@@ -3098,35 +3111,57 @@ impl Core {
|
||||
Errno::EIO
|
||||
}
|
||||
})?;
|
||||
let meta_json = serde_json::to_string(&meta).unwrap_or_default();
|
||||
// Whatever this write supersedes, it supersedes now — including an
|
||||
// upload already on the wire, whose bytes describe a revision the row
|
||||
// written just below replaces. Signalled before the enqueue so the
|
||||
// in-flight reader stops at its next block rather than after however
|
||||
// much of the file is left.
|
||||
self.cancel_upload(uid);
|
||||
let superseded = if is_local_uid(uid) {
|
||||
// The node has no server-side identity to hang a revision on, so the
|
||||
// bytes ride on the create that will mint it.
|
||||
let attached = self
|
||||
.db
|
||||
.attach_blob_to_create(&uid.to_string(), &path.to_string_lossy(), &meta_json)
|
||||
.map_err(|e| {
|
||||
error!(%uid, error = %e, "attaching write to queued create failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
match attached {
|
||||
Some(a) => a.superseded,
|
||||
None => {
|
||||
// The create drained between `release` and here, so the node
|
||||
// has a real uid now and this handle's is stale. The bytes are
|
||||
// safe in staging, but nothing here can address them.
|
||||
let mut uid = uid.clone();
|
||||
let len = meta.len;
|
||||
let superseded = 'queued: {
|
||||
if is_local_uid(&uid) {
|
||||
// The node has no server-side identity to hang a revision on, so
|
||||
// the bytes ride on the create that will mint it. Attached and
|
||||
// recorded under one hold of `pending`, which is what the drain
|
||||
// takes to retire a landed create: it either sees this blob and
|
||||
// carries it over, or has already finished and this write
|
||||
// queues against the real uid below (`Core::retire_create`).
|
||||
let meta_json = serde_json::to_string(&meta).unwrap_or_default();
|
||||
let mut pending = self.pending.lock();
|
||||
let attached = self
|
||||
.db
|
||||
.attach_blob_to_create(&uid.to_string(), &path.to_string_lossy(), &meta_json)
|
||||
.map_err(|e| {
|
||||
error!(%uid, error = %e, "attaching write to queued create failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
if let Some(attached) = attached {
|
||||
pending.insert(uid.clone(), PendingRevision { path, meta });
|
||||
break 'queued attached.superseded;
|
||||
}
|
||||
drop(pending);
|
||||
// The create landed while this handle was open. Its uid is the
|
||||
// one the drain has just given the inode.
|
||||
let Some(real) = self.landed_uid(ino) else {
|
||||
error!(%uid, staged = %path.display(),
|
||||
"queued create vanished under a write; bytes kept in staging");
|
||||
return Err(Errno::EIO);
|
||||
};
|
||||
debug!(local = %uid, %real, "create landed under an open write; queueing a revision");
|
||||
uid = real;
|
||||
meta.uid = uid.to_string();
|
||||
let previous = self.pending.lock().get(&uid).cloned();
|
||||
meta.based_on = previous.as_ref().and_then(|p| p.meta.based_on.clone());
|
||||
if !meta.complete
|
||||
&& let Some(previous) = previous
|
||||
&& let Err(error) = merge_over_pending(&mut meta, &path, &previous)
|
||||
{
|
||||
error!(%uid, %error, staged = %path.display(),
|
||||
"folding in the write the create carried failed; bytes kept in staging");
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let op = PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
@@ -3134,25 +3169,25 @@ impl Core {
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: Some(path.to_string_lossy().into_owned()),
|
||||
meta_json: Some(meta_json),
|
||||
meta_json: Some(serde_json::to_string(&meta).unwrap_or_default()),
|
||||
created_at: now_millis(),
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: now_millis() + self.revision_debounce(uid).as_millis() as i64,
|
||||
next_attempt_at: now_millis() + self.revision_debounce(&uid).as_millis() as i64,
|
||||
};
|
||||
let (_id, superseded) = self.db.enqueue_op(&op).map_err(|e| {
|
||||
error!(%uid, error = %e, "queueing upload failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
self.pending
|
||||
.lock()
|
||||
.insert(uid.clone(), PendingRevision { path, meta });
|
||||
superseded
|
||||
};
|
||||
if let Some(old) = superseded {
|
||||
self.cache.discard_staged(Path::new(&old));
|
||||
}
|
||||
let len = meta.len;
|
||||
self.pending
|
||||
.lock()
|
||||
.insert(uid.clone(), PendingRevision { path, meta });
|
||||
let uid = &uid;
|
||||
// Reflect the write in the tree straight away: `ls` must show the new
|
||||
// size and mtime even though the remote still holds the old revision.
|
||||
let now = now_secs();
|
||||
@@ -3187,6 +3222,23 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The real uid the drain gave `ino` when its queued create landed.
|
||||
///
|
||||
/// Called by a write that found the create already retired. The drain
|
||||
/// repoints the inode right after it drops `pending`, with no network in
|
||||
/// between, so a short wait covers the gap; `None` means the inode is gone
|
||||
/// or never stopped being a placeholder.
|
||||
fn landed_uid(&self, ino: u64) -> Option<NodeUid> {
|
||||
for _ in 0..LANDED_UID_POLLS {
|
||||
let uid = self.state().entries.get(&ino).map(|e| e.uid.clone())?;
|
||||
if !is_local_uid(&uid) {
|
||||
return Some(uid);
|
||||
}
|
||||
std::thread::sleep(LANDED_UID_POLL);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Queue the new content of a path-based truncate — `> file`, or any
|
||||
/// `setattr(size=…)` arriving without a write handle.
|
||||
///
|
||||
@@ -3457,10 +3509,7 @@ impl Core {
|
||||
error!(%uid, error = %e, "queueing trash failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
for blob in blobs {
|
||||
self.cache.discard_staged(Path::new(&blob));
|
||||
}
|
||||
self.pending.lock().remove(uid);
|
||||
self.release_dropped_ops(uid, blobs);
|
||||
self.hidden.lock().insert(uid.clone());
|
||||
// Withdrawn from every mount, not just the one the unlink came through:
|
||||
// a sync folder maps a remote folder that also exists under My Files, so
|
||||
@@ -3486,11 +3535,36 @@ impl Core {
|
||||
error!(%uid, error = %e, "dropping queued ops failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
self.release_dropped_ops(uid, blobs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Let go of what the ops just dropped for `uid` held: their pending
|
||||
/// entries, any upload still reading their bytes, and the bytes.
|
||||
///
|
||||
/// Removing a folder also drops the queued revisions of the files below it
|
||||
/// (`docs/BUGS.md` B102), so the entries to forget are found by blob, not
|
||||
/// only by `uid`.
|
||||
fn release_dropped_ops(&self, uid: &NodeUid, blobs: Vec<String>) {
|
||||
let below: Vec<NodeUid> = {
|
||||
let mut pending = self.pending.lock();
|
||||
pending.remove(uid);
|
||||
let below: Vec<NodeUid> = pending
|
||||
.iter()
|
||||
.filter(|(_, p)| blobs.iter().any(|b| p.path == Path::new(b)))
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for k in &below {
|
||||
pending.remove(k);
|
||||
}
|
||||
below
|
||||
};
|
||||
for k in &below {
|
||||
self.cancel_upload(k);
|
||||
}
|
||||
for blob in blobs {
|
||||
self.cache.discard_staged(Path::new(&blob));
|
||||
}
|
||||
self.pending.lock().remove(uid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Which staged blob is currently queued for `uid`, if any.
|
||||
@@ -4317,6 +4391,11 @@ impl Core {
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
.and_then(batch::into_unit)
|
||||
.map_err(|e| CoreError::from_api(&e, "trash"))?;
|
||||
// Uploads queued for the node or anything below it would now only fail
|
||||
// against the trash, forever, holding their bytes (`docs/BUGS.md` B102).
|
||||
if let Err(error) = self.discard_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
||||
}
|
||||
let name = self
|
||||
.state
|
||||
.lock()
|
||||
@@ -4483,10 +4562,8 @@ impl Core {
|
||||
/// end. Every batch is a usable listing.
|
||||
async fn refresh_trash(&self) -> CoreResult<()> {
|
||||
let started = Instant::now();
|
||||
let uids = self
|
||||
.client
|
||||
.enumerate_trash_node_uids()
|
||||
.await
|
||||
let uids = within_trash_call("enumerate trash", self.client.enumerate_trash_node_uids())
|
||||
.await?
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate trash"))?;
|
||||
info!(
|
||||
count = uids.len(),
|
||||
@@ -4512,10 +4589,8 @@ impl Core {
|
||||
let mut items: Vec<StoredTrash> = Vec::with_capacity(uids.len());
|
||||
for chunk in uids.chunks(TRASH_MATERIALIZE_CHUNK) {
|
||||
let chunk_started = Instant::now();
|
||||
let nodes = self
|
||||
.client
|
||||
.enumerate_nodes(chunk)
|
||||
.await
|
||||
let nodes = within_trash_call("enumerate nodes", self.client.enumerate_nodes(chunk))
|
||||
.await?
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
items.extend(nodes.into_iter().map(|node| StoredTrash {
|
||||
uid: node.uid.to_string(),
|
||||
@@ -5556,6 +5631,19 @@ fn node_size(node: &Node) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `call`, failed with a remote error once it has run for
|
||||
/// [`TRASH_CALL_TIMEOUT`].
|
||||
async fn within_trash_call<T>(what: &str, call: impl Future<Output = T>) -> CoreResult<T> {
|
||||
tokio::time::timeout(TRASH_CALL_TIMEOUT, call)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CoreError::new(
|
||||
ErrorKind::Remote,
|
||||
format!("{what}: no answer within {}s", TRASH_CALL_TIMEOUT.as_secs()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// When a node a trash refresh found went to the trash, as far as the daemon
|
||||
/// can tell. A node the stored listing already had keeps the time it had. A new
|
||||
/// one went there since the last refresh, so now is close, but only when that
|
||||
@@ -7848,7 +7936,7 @@ mod tests {
|
||||
assert_before(function, "require_uid_writable", first_side_effect);
|
||||
}
|
||||
let trash = function_source(source, "fn queue_trash(");
|
||||
assert_before(trash, "replace_ops_with_trash", "discard_staged");
|
||||
assert_before(trash, "replace_ops_with_trash", "release_dropped_ops");
|
||||
|
||||
// §5, parallel drain: every path that unlinks a staged blob or hands
|
||||
// the same node's work to another worker has to stop the upload that
|
||||
|
||||
@@ -128,6 +128,23 @@ pub(crate) struct WriteHandle {
|
||||
pub(crate) open_count: usize,
|
||||
}
|
||||
|
||||
impl WriteHandle {
|
||||
/// Account for `[offset, end)` having been written through this handle.
|
||||
///
|
||||
/// A write past the end leaves a hole that reads as zeros, exactly like a
|
||||
/// grow through `setattr`, so the hole is authored too. Left unauthored, a
|
||||
/// hole below the base size was refilled from the base at commit: a file
|
||||
/// shrunk and then written past its new end got its old bytes back.
|
||||
pub(crate) fn record_write(&mut self, offset: u64, end: u64) {
|
||||
if offset > self.len {
|
||||
self.written.add(self.len, offset);
|
||||
}
|
||||
self.written.add(offset, end);
|
||||
self.len = self.len.max(end);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1863,4 +1880,50 @@ mod tests {
|
||||
let shared = st.intern(owned_root, shared);
|
||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||
}
|
||||
|
||||
fn handle(len: u64, base_size: u64) -> WriteHandle {
|
||||
let path = std::env::temp_dir().join(format!("pdfs-handle-test-{}", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
WriteHandle {
|
||||
ino: 2,
|
||||
uid: uid("file"),
|
||||
file: Arc::new(file),
|
||||
path,
|
||||
written: Intervals::default(),
|
||||
len,
|
||||
base_size,
|
||||
base_mtime: 0,
|
||||
base_revision_id: None,
|
||||
dirty: false,
|
||||
open_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_past_the_end_authors_the_hole_it_leaves() {
|
||||
// Opened over a 100-byte base, shrunk to 10, then written at 50: bytes
|
||||
// 10..50 are zeros now, not the base's bytes 10..50.
|
||||
let mut h = handle(100, 100);
|
||||
h.written.clip(10);
|
||||
h.len = 10;
|
||||
h.record_write(50, 60);
|
||||
assert_eq!(h.len, 60);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 60),
|
||||
vec![(0, 10, false), (10, 60, true)],
|
||||
"only the head still comes from the base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_inside_the_file_leaves_the_rest_to_the_base() {
|
||||
let mut h = handle(100, 100);
|
||||
h.record_write(40, 50);
|
||||
assert_eq!(h.len, 100);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 100),
|
||||
vec![(0, 40, false), (40, 50, true), (50, 100, false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user