fix(fuse,core): what a create sent, and where, outlives a restart and a rename (B182, B183, B184)

The known-bug confirmation run failed one_client_on_a_flaky_link seeds 6
and 11 on three bugs around a create whose answer was lost:

- B182: after a restart, a write over a file whose create landed but was
  never read back forked a conflict copy. A revision of a file that has
  no revision of Drive's now adopts the landed node first.
- B183: the SHA-1s a create sent were kept in memory, so after a restart
  its trash or retry no longer knew the node Drive made. They are now on
  the op's row (pending_op.sent_sha1, schema 42).
- B184: a rename rewrote the op's target, and the trash and the retry
  looked for the node under the last name only. Each attempt's parent
  and name are recorded (pending_op.sent_to); the trash looks under all
  of them, and a retry adopts the node an earlier attempt made and moves
  it to the current name.
This commit is contained in:
nvrl committed 2026-10-05 02:01:50 +02:00
1 parent a8ed6060af
commit 307305282e
10 files changed
+548 -49

No files matched your search

+29 -1
View File
@@ -11,7 +11,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 41;
pub(super) const SCHEMA_VERSION: i64 = 42;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -424,6 +424,22 @@ impl Db {
tx.execute_batch(MIGRATION_V41)?;
}
}
if current < 42 {
// Same guards as V26-V40.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'sent_sha1'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_ops: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_ops && !has_column {
tx.execute_batch(MIGRATION_V42)?;
}
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1302,3 +1318,15 @@ UPDATE nodes
UPDATE nodes SET uid = 'local~' || lid WHERE lid IN (SELECT lid FROM local_v41);
DROP TABLE local_v41;
";
/// Schema v42: the SHA-1 of each blob a queued create sent, space-separated.
/// A create whose answer was lost may have made its file with any of them,
/// and the op that finds that file again may run after a restart. `NULL` for
/// an op that has sent nothing, which is every op migrated: what the daemon
/// that wrote it sent is not known. Likewise the parent and name each attempt
/// was sent under, as a JSON list of pairs: a rename since then moves the op
/// but not the file Drive may have made.
const MIGRATION_V42: &str = "
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
";
+83
View File
@@ -765,6 +765,89 @@ impl Db {
})
}
/// Record that queued create `id` is sending a blob with SHA-1 `sha`.
///
/// Drive may make the file and lose its answer. The op that next finds
/// the file, the create's retry or the trash a delete made of it, takes
/// it for its own when it holds any bytes sent so. Kept on the row, so a
/// newer blob, the turn into a trash and a restart all keep it
/// (`docs/BUGS.md` B156, B183).
pub fn note_create_sent(&self, id: i64, sha: &str) -> Result<()> {
self.conn.lock().execute(
"UPDATE pending_op
SET sent_sha1 = CASE
WHEN sent_sha1 IS NULL THEN ?2
WHEN instr(' ' || sent_sha1 || ' ', ' ' || ?2 || ' ') > 0 THEN sent_sha1
ELSE sent_sha1 || ' ' || ?2 END
WHERE id = ?1",
params![id, sha],
)?;
Ok(())
}
/// Record that queued create `id` is being sent to `parent` under `name`.
///
/// A rename while the op waits rewrites its target, but the file a lost
/// answer left stays where it was sent. The retry and the trash look
/// there too (`docs/BUGS.md` B184).
pub fn note_create_target(&self, id: i64, parent: &str, name: &str) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let sent: Option<String> = tx
.query_row("SELECT sent_to FROM pending_op WHERE id = ?1", [id], |r| {
r.get(0)
})
.optional()?
.flatten();
let mut targets: Vec<(String, String)> = sent
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
let target = (parent.to_string(), name.to_string());
if !targets.contains(&target) {
targets.push(target);
tx.execute(
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
params![id, serde_json::to_string(&targets)?],
)?;
}
tx.commit()?;
Ok(())
}
/// The parent and name queued op `id` was sent under as a create, in the
/// order it was ([`Db::note_create_target`]).
pub fn create_targets(&self, id: i64) -> Result<Vec<(String, String)>> {
let sent: Option<String> = self
.conn
.lock()
.query_row("SELECT sent_to FROM pending_op WHERE id = ?1", [id], |r| {
r.get(0)
})
.optional()?
.flatten();
Ok(sent
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default())
}
/// The SHA-1 of each blob queued op `id` sent as a create
/// ([`Db::note_create_sent`]).
pub fn create_sent(&self, id: i64) -> Result<Vec<String>> {
let sent: Option<String> = self
.conn
.lock()
.query_row(
"SELECT sent_sha1 FROM pending_op WHERE id = ?1",
[id],
|r| r.get(0),
)
.optional()?
.flatten();
Ok(sent
.map(|sent| sent.split(' ').map(str::to_string).collect())
.unwrap_or_default())
}
/// Replace the sidecar of a queued op, for a baseline that has moved under
/// it.
///
+63
View File
@@ -3848,6 +3848,69 @@ fn a_create_withdrawn_after_it_failed_queues_a_trash_that_keeps_its_blob() {
assert!(db.pending_ops().unwrap().is_empty());
}
/// What a queued create sent, and where, stays on its row: through a newer
/// blob, through the turn into a trash a delete makes of it, and across a
/// reopen. The trash finds the file the create made by those bytes, after a
/// restart too, and under a name the file had before a rename
/// (`docs/BUGS.md` B183, B184).
#[test]
fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
let path = std::env::temp_dir().join(format!(
"pdfs-create-sent-{}-{}.db",
std::process::id(),
now_test_id()
));
let parent = uid("parent").to_string();
let id = {
let db = Db::open(&path).unwrap();
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("d".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();
assert!(db.create_sent(id).unwrap().is_empty());
assert!(db.create_targets(id).unwrap().is_empty());
db.note_create_target(id, &parent, "c").unwrap();
db.note_create_target(id, &parent, "c").unwrap();
db.note_create_sent(id, "aaaa").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");
db.note_create_target(id, &parent, "d").unwrap();
db.note_create_sent(id, "bbbb").unwrap();
db.record_op_failure(id, "no answer", 0).unwrap();
db.delete_ops_for_uid("local~sent").unwrap();
id
};
let db = Db::open(&path).unwrap();
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!(db.create_sent(id).unwrap(), vec!["aaaa", "bbbb"]);
assert_eq!(
db.create_targets(id).unwrap(),
vec![
(parent.clone(), "c".to_string()),
(parent.clone(), "d".to_string())
]
);
drop(db);
let _ = std::fs::remove_file(path);
}
/// 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).