From 57a825887c1d92159b8d5a46e3bf6922cd7faf2f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 5 Oct 2026 04:50:02 +0200 Subject: [PATCH] fix(core): a file deleted after its failed create was written again leaves Drive (B191) --- crates/pdfs-core/src/db/ops.rs | 7 +++++- crates/pdfs-core/src/db/tests.rs | 42 ++++++++++++++++++++++++++++++++ docs/BUGS.md | 19 +++++++++++++++ docs/MILESTONE-3.0.0-STATUS.md | 6 +++-- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/pdfs-core/src/db/ops.rs b/crates/pdfs-core/src/db/ops.rs index bd29487..0b04179 100644 --- a/crates/pdfs-core/src/db/ops.rs +++ b/crates/pdfs-core/src/db/ops.rs @@ -107,6 +107,10 @@ pub fn op_supersedes(kind: &str) -> bool { /// name and bytes (`docs/BUGS.md` B151). Such a trash keeps the create's /// parent and blob, which the drain looks for the node by, and which it /// discards once the trash is done ([`Db::complete_trash_op`]). +/// +/// Sent is where it was sent to ([`Db::note_create_target`]), not only its +/// attempts: a write after the failure clears those for a prompt retry, and +/// the delete then dropped the create whole (`docs/BUGS.md` B191). fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result> { const DOOMED: &str = " WITH RECURSIVE @@ -134,7 +138,8 @@ fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result = { let mut stmt = tx.prepare(&format!( diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 41f29ca..a930bb5 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -3951,6 +3951,48 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() { let _ = std::fs::remove_file(path); } +/// A write after a create failed clears its attempts, so the next try goes +/// out at once. The create was still sent: deleting the file then left no +/// trash, and the file a lost answer made stayed on Drive under the name it +/// was sent with (`docs/BUGS.md` B191). +#[test] +fn a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted() { + let db = Db::open_in_memory().unwrap(); + let parent = uid("parent").to_string(); + let (id, _) = db + .enqueue_op(&PendingOp { + id: 0, + kind: OP_CREATE.to_string(), + uid: "local~sent".to_string(), + parent_uid: Some(parent.clone()), + name: Some("a.txt".to_string()), + blob_path: Some("/staging/first".to_string()), + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + db.note_create_target(id, &parent, "a.txt").unwrap(); + db.note_create_sent(id, "aaaa").unwrap(); + db.record_op_failure(id, "no answer", 0).unwrap(); + db.attach_blob_to_create("local~sent", "/staging/second", "{}") + .unwrap() + .expect("the create is still queued"); + + assert!(db.delete_ops_for_uid("local~sent").unwrap().is_empty()); + + let ops = db.pending_ops().unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!((ops[0].id, ops[0].kind.as_str()), (id, OP_TRASH)); + assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second")); + assert_eq!( + db.create_targets(id).unwrap(), + vec![(parent.clone(), "a.txt".to_string())] + ); +} + /// A create deleted after it was sent becomes a trash in its place. The /// create's backoff, and the failure of an attempt still on the wire, are the /// create's: the trash is due at once (`docs/BUGS.md` B171). diff --git a/docs/BUGS.md b/docs/BUGS.md index 35f7806..a677835 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,25 @@ Conventions: --- +## B191 — A file deleted after its create failed and was written again can stay on Drive + +**Status:** Fixed (unverified). +**Found:** 2026-10-05, in the confirmation run on 1436c5f (`one_client_on_a_flaky_link` seed 11, +one replay in six). A create of `a.txt` got no answer, though Drive had made the file. The file +was written again, renamed twice and deleted. Drive kept `a.txt`, and nothing was queued. + +**Where:** `drop_doomed_ops` in `crates/pdfs-core/src/db/ops.rs`. + +**Cause.** A delete turns a create that was sent into a trash of what it may have made (B151). +Whether it was sent was read from its attempts and its claim. A write after the failure attaches +its bytes to the create and clears the attempts, so that the retry goes out at once +(`Db::attach_blob_to_create`). The delete then took the create for one never sent, and dropped +it whole. + +**Fix.** A create with a target recorded (`sent_to`, B184) counts as sent as well. Test +`a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted` in +`crates/pdfs-core/src/db/tests.rs`. + ## B190 — A file saved just before the daemon stops can keep its old content on Drive **Status:** Fixed (unverified). diff --git a/docs/MILESTONE-3.0.0-STATUS.md b/docs/MILESTONE-3.0.0-STATUS.md index febb803..fa65b79 100644 --- a/docs/MILESTONE-3.0.0-STATUS.md +++ b/docs/MILESTONE-3.0.0-STATUS.md @@ -9,7 +9,7 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase - **Phase 2**: local ids, inode = local id, one tree for every mount (2.10.0). - **Phase 3, first part**: mkdir, create, rename, unlink and rmdir on the mount are recorded locally and sent from the queue; `"local_first": false` brings back the old path (5e0f30c). -- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B190. +- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B191. - **Sync issues, first cut** (§6.2): refusals sorted into issues, shown at once in the app, tray and CLI; Export button and `pdfs sync export`; `pdfs sync issues`; Discard button and `pdfs sync discard`; a warning badge on the node in the file browser. @@ -116,7 +116,9 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase The run on 2baad67 found that a restart before such a file's trash went out queued its kept bytes again, for good (B188, fixed), and changes that sat out their retry after an outage only the drain had met (B189, fixed; older than 3.0.0), and a file - saved just before a stop whose release the stop dropped (B190, fixed; older too). + saved just before a stop whose release the stop dropped (B190, fixed; older too). The run + on 1436c5f found a file deleted after its failed create was written again left on Drive + (B191, fixed). - [ ] A clean account run. The one on a6f36d5 (2026-10-04, LAN: 427 passed, 12 failed) failed B113 and B121 on every on-demand mount, two mirror-to-mirror move cases, and the journal check. B121 was a bug (B149, fixed); B113's case still expected creates