fix(core,fuse): a deleted file's trash is not held back by its create (B171)

A file deleted after its create was sent keeps the op as a trash. The op
kept the create's attempts and backoff, and the drain recorded the failure
of a create attempt still on the wire on the row that was by then the trash.
The trash waited out the create's backoff.

The trash starts due, as a create that becomes a revision does, and the
drain records a failed attempt only if the op is still the kind it ran as
(`Db::record_attempt_failure`).
This commit is contained in:
nvrl committed 2026-10-04 21:23:10 +02:00
1 parent 2502af6e3c
commit e32f72bfca
6 files changed
+116 -5

No files matched your search

+28 -2
View File
@@ -151,6 +151,7 @@ fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<Stri
"{DOOMED}
UPDATE pending_op
SET kind = 'trash', issue = NULL,
attempts = 0, last_error = NULL, next_attempt_at = 0,
parent_uid = CASE WHEN {PARENT_NOW} LIKE '{LOCAL_VOLUME}~%' THEN NULL
ELSE {PARENT_NOW} END,
parent_lid = CASE WHEN {PARENT_NOW} LIKE '{LOCAL_VOLUME}~%' THEN NULL
@@ -1276,6 +1277,31 @@ impl Db {
/// 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<()> {
self.record_failure(id, None, error, next_attempt_at)
}
/// [`Db::record_op_failure`] for an attempt at op `id` as a `kind` op. An
/// op that has become another kind while the attempt ran is left as it
/// is: a create deleted on the wire is a trash now, and the create's
/// failure would hold the trash back for the create's backoff
/// (`docs/BUGS.md` B171).
pub fn record_attempt_failure(
&self,
id: i64,
kind: &str,
error: &str,
next_attempt_at: i64,
) -> Result<()> {
self.record_failure(id, Some(kind), error, next_attempt_at)
}
fn record_failure(
&self,
id: i64,
kind: Option<&str>,
error: &str,
next_attempt_at: i64,
) -> Result<()> {
let conn = self.conn.lock();
if drop_replaced_rename(&conn, id)? {
return Ok(());
@@ -1284,8 +1310,8 @@ impl Db {
"UPDATE pending_op
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
access_deferred_since = 0
WHERE id = ?1",
params![id, error, next_attempt_at],
WHERE id = ?1 AND (?4 IS NULL OR kind = ?4)",
params![id, error, next_attempt_at, kind],
)?;
Ok(())
}
+55
View File
@@ -3726,6 +3726,61 @@ fn a_create_withdrawn_after_it_failed_queues_a_trash_that_keeps_its_blob() {
assert!(db.pending_ops().unwrap().is_empty());
}
/// 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).
#[test]
fn a_create_deleted_after_it_was_sent_leaves_a_trash_that_is_due_at_once() {
let db = Db::open_in_memory().unwrap();
let parent = uid("parent").to_string();
let create = |local: &str| {
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: local.to_string(),
parent_uid: Some(parent.clone()),
name: Some(format!("{local}.txt")),
blob_path: Some(format!("/staging/{local}")),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap()
.0
};
let failed = create("local~failed");
db.record_attempt_failure(failed, OP_CREATE, "no answer", 50_000)
.unwrap();
let sending = create("local~sending");
assert_eq!(
db.claim_next_due_op(10).unwrap().map(|op| op.id),
Some(sending)
);
db.delete_ops_for_uid("local~failed").unwrap();
db.delete_ops_for_uid("local~sending").unwrap();
db.record_attempt_failure(sending, OP_CREATE, "No such file or directory", 50_000)
.unwrap();
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 2);
for op in &ops {
assert_eq!(op.kind, OP_TRASH);
assert_eq!((op.attempts, op.last_error.as_deref()), (0, None), "{op:?}");
assert_eq!(op.next_attempt_at, 0, "{op:?}");
}
db.record_attempt_failure(failed, OP_TRASH, "offline", 50_000)
.unwrap();
let trash = db
.pending_ops()
.unwrap()
.into_iter()
.find(|op| op.id == failed);
assert_eq!(trash.map(|op| op.attempts), Some(1));
}
#[test]
fn a_landed_placeholder_never_overwrites_the_server_copy() {
let db = Db::open_in_memory().unwrap();
+2 -1
View File
@@ -394,8 +394,9 @@ impl Core {
.min(DRAIN_BACKOFF_MAX)
};
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
if let Err(e) = self.db.record_op_failure(
if let Err(e) = self.db.record_attempt_failure(
op.id,
&op.kind,
&err_str,
now_millis() + backoff.as_millis() as i64,
) {