mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(fuse,core): record namespace changes locally and send them from the queue
A new folder, a new file, a rename or a delete through the mount no longer waits for Drive: it is recorded locally, the call returns at once, and the drain sends it as it does offline. `"local_first": false` in config.json brings back the old path for this release. Fixes found by the simulation runs on the way: - B129: a file deleted while its first upload is on the wire is trashed once the upload lands. - B132, B140: files made in a new folder survive a restart; a landed folder hands its queued children over in the stored node too. - B138: a create waits for the rename or trash that frees its name. - B139: a file closed while its first upload lands keeps its last write. - B141: a rename a worker holds stays queued, and the newer one waits behind it instead of racing it. - B142: a rename of a file whose create lands meanwhile goes to the uid Drive gave it instead of answering EBUSY. - B143: a create whose read-back fails as the link drops is adopted before the next op for it is sent, so its next write is not a conflict copy.
This commit is contained in:
1 parent
b3e33a7002
commit
5e0f30cc2c
17 files changed
+1271
-111
No files matched your search
@@ -154,6 +154,13 @@ pub struct AppConfig {
|
||||
/// configs predating the field.
|
||||
#[serde(default)]
|
||||
pub online_map: bool,
|
||||
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||
/// syscall. `None` means on. `false` brings back the old path for one
|
||||
/// release (docs/MILESTONE-3.0.0.md §10). Defaulted for configs predating
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
}
|
||||
|
||||
/// The My Files page's view choices, remembered between runs.
|
||||
@@ -228,6 +235,7 @@ impl Default for AppConfig {
|
||||
tray_hidden: false,
|
||||
language: None,
|
||||
online_map: false,
|
||||
local_first: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,6 +592,7 @@ mod tests {
|
||||
tray_hidden: true,
|
||||
language: Some("de".to_string()),
|
||||
online_map: true,
|
||||
local_first: Some(false),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -56,9 +56,9 @@ pub use local::LocalFileHit;
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, CreateLanding, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME,
|
||||
OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta,
|
||||
op_supersedes,
|
||||
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
|
||||
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
|
||||
PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::HashSet;
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
|
||||
use super::Db;
|
||||
use super::ops::{OP_CREATE, OP_MKDIR, OP_RENAME, wake_ops_waiting_for_tx};
|
||||
use crate::{Access, Result};
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
@@ -266,10 +267,22 @@ impl Db {
|
||||
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||
/// that row after a crash; removing the row first could strand a now
|
||||
/// unauthorizable op. One transaction closes both windows.
|
||||
///
|
||||
/// An op that found the name still held by this trash failed with a
|
||||
/// backoff, and the name is free now. It is made due at once
|
||||
/// ([`Db::wake_ops_waiting_for`]).
|
||||
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
|
||||
let uid = uid.to_string();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let name: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT name FROM pending_op WHERE id = ?1",
|
||||
params![op_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
let rowid: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
@@ -282,6 +295,9 @@ impl Db {
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||
}
|
||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||
if let Some(name) = name {
|
||||
wake_ops_waiting_for_tx(&tx, &name)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -549,6 +565,18 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
/// The uid of the node with local id `lid`: `None` when it has no row, or
|
||||
/// is not on Drive yet.
|
||||
pub fn uid_of_lid(&self, lid: i64) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row("SELECT uid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// The local id of the node stored under `uid`, or `None` when it has no
|
||||
/// row. It stays the same for as long as the row exists, including when a
|
||||
/// create lands and the row takes the uid Drive gave it.
|
||||
@@ -636,6 +664,40 @@ impl Db {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a queued change does to `parent`'s listing, which Drive does not
|
||||
/// show until it lands: the children made or moved in by a queued create,
|
||||
/// mkdir or rename, as the DB holds them, and the nodes a queued rename
|
||||
/// moves elsewhere.
|
||||
///
|
||||
/// A listing read from Drive is laid over with these, or a folder listed
|
||||
/// while a create in it is queued, after a restart say, loses the file
|
||||
/// until the create lands (`docs/BUGS.md` B132).
|
||||
pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> {
|
||||
let conn = self.read();
|
||||
let parent = parent.to_string();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json FROM nodes n
|
||||
WHERE n.parent_uid = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0
|
||||
AND EXISTS (SELECT 1 FROM pending_op p
|
||||
WHERE p.uid = n.uid AND p.kind IN (?2, ?3, ?4))",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?;
|
||||
let mut here = Vec::new();
|
||||
for json in rows {
|
||||
here.push(serde_json::from_str(&json?)?);
|
||||
}
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT p.uid FROM pending_op p JOIN nodes n ON n.uid = p.uid
|
||||
WHERE p.kind = ?2 AND n.parent_uid IS NOT ?1",
|
||||
)?;
|
||||
let gone = stmt
|
||||
.query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<HashSet<_>, _>>()?;
|
||||
Ok((here, gone))
|
||||
}
|
||||
|
||||
// --- Content-cache LRU index (P4) -------------------------------------
|
||||
//
|
||||
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
|
||||
@@ -671,9 +733,18 @@ pub(super) fn adopt_placeholder_row_tx(
|
||||
local: &str,
|
||||
real: &str,
|
||||
) -> Result<()> {
|
||||
// The node a row holds names its parent too, and is what a restart builds
|
||||
// the tree from: a folder that landed with no write to its children after
|
||||
// left them under a parent no row has (`docs/BUGS.md` B140).
|
||||
let real_json = parse_node_uid(real)
|
||||
.map(|uid| serde_json::to_string(&uid))
|
||||
.transpose()?;
|
||||
tx.execute(
|
||||
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
|
||||
params![local, real],
|
||||
"UPDATE nodes SET parent_uid = ?2,
|
||||
node_json = CASE WHEN ?3 IS NULL OR node_json IS NULL THEN node_json
|
||||
ELSE json_set(node_json, '$.parent_uid', json(?3)) END
|
||||
WHERE parent_uid = ?1",
|
||||
params![local, real, real_json],
|
||||
)?;
|
||||
let row: Option<(i64, Option<String>)> = tx
|
||||
.query_row(
|
||||
|
||||
+174
-11
@@ -92,6 +92,13 @@ pub fn op_supersedes(kind: &str) -> bool {
|
||||
/// 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.
|
||||
///
|
||||
/// A create already on the wire cannot be stopped, so its row is not deleted
|
||||
/// but turned into a trash of the node it is making, still claimed by the
|
||||
/// upload. [`Db::finish_create`] points it at the landed node. Until then it
|
||||
/// holds the name, as any queued trash does ([`Db::has_pending_trash_named`]):
|
||||
/// a rename over the file sent first found the name taken and landed under a
|
||||
/// conflict name (`docs/BUGS.md` B129).
|
||||
fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<String>> {
|
||||
const DOOMED: &str = "
|
||||
WITH RECURSIVE
|
||||
@@ -124,12 +131,48 @@ fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<Stri
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{DOOMED} DELETE FROM pending_op WHERE id IN (SELECT id FROM doomed)"),
|
||||
&format!(
|
||||
"{DOOMED}
|
||||
UPDATE pending_op
|
||||
SET kind = 'trash', parent_uid = NULL, blob_path = NULL, meta_json = NULL
|
||||
WHERE id IN (SELECT id FROM doomed)
|
||||
AND kind IN ('create', 'mkdir') AND claimed_at <> 0"
|
||||
),
|
||||
params![uid],
|
||||
)?;
|
||||
tx.execute(
|
||||
&format!(
|
||||
"{DOOMED}
|
||||
DELETE FROM pending_op
|
||||
WHERE id IN (SELECT id FROM doomed)
|
||||
AND NOT (kind = 'trash' AND claimed_at <> 0 AND uid LIKE '{LOCAL_VOLUME}~%')"
|
||||
),
|
||||
params![uid],
|
||||
)?;
|
||||
Ok(blobs)
|
||||
}
|
||||
|
||||
/// The `pending_op` rows, `?1` being [`OP_RENAME`], that move a node away from
|
||||
/// the name `?2`.
|
||||
const MOVES_FROM: &str = "kind = ?1
|
||||
AND json_extract(meta_json, '$.original_name') = ?2
|
||||
AND (name <> ?2 OR parent_uid <> json_extract(meta_json, '$.original_parent_uid'))";
|
||||
|
||||
/// [`Db::wake_ops_waiting_for`] within a transaction.
|
||||
pub(super) fn wake_ops_waiting_for_tx(tx: &rusqlite::Transaction<'_>, name: &str) -> Result<()> {
|
||||
tx.execute(
|
||||
&format!(
|
||||
"UPDATE pending_op SET next_attempt_at = 0
|
||||
WHERE name = ?2 AND kind IN (?3, ?4, ?1) AND attempts > 0
|
||||
AND next_attempt_at < ?5
|
||||
AND NOT EXISTS (SELECT 1 FROM pending_op WHERE kind = ?6 AND name = ?2)
|
||||
AND NOT EXISTS (SELECT 1 FROM pending_op WHERE {MOVES_FROM})"
|
||||
),
|
||||
params![OP_RENAME, name, OP_CREATE, OP_MKDIR, PARK_UNTIL, OP_TRASH],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -242,6 +285,20 @@ pub struct CreateLanding<'a> {
|
||||
pub landed: (&'a str, &'a str),
|
||||
}
|
||||
|
||||
/// What became of a queued create when it landed, from [`Db::finish_create`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CreateRetired {
|
||||
/// Its row is gone and nothing of it is left queued.
|
||||
Landed,
|
||||
/// A newer write attached to it on the wire and is now queued as a
|
||||
/// revision of the real node: its blob and its rewritten sidecar.
|
||||
Newer { blob: String, meta: String },
|
||||
/// Its row had been dropped while it was on the wire: the node was deleted,
|
||||
/// or a rename replaced it. The node Drive just made is unwanted, and a
|
||||
/// trash of it is queued under this id, claimed by the caller.
|
||||
Withdrawn { trash: i64 },
|
||||
}
|
||||
|
||||
struct CreateRow {
|
||||
blob: Option<String>,
|
||||
meta: Option<String>,
|
||||
@@ -278,6 +335,32 @@ impl Db {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Whether a queued rename moves a node away from `name`.
|
||||
///
|
||||
/// Like a queued trash ([`Db::has_pending_trash_named`]) it frees the name
|
||||
/// once it lands, so an op that wants the name can wait for it. A create
|
||||
/// renamed while it was on the wire queues one (`Db::finish_create`).
|
||||
pub fn has_pending_move_from(&self, name: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM pending_op WHERE {MOVES_FROM})"),
|
||||
params![OP_RENAME, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Make the ops that failed because `name` was held due at once, now that
|
||||
/// what held it let go. Nothing changes while another queued trash or
|
||||
/// rename still holds it, or for a parked op.
|
||||
pub fn wake_ops_waiting_for(&self, name: &str) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
wake_ops_waiting_for_tx(&tx, name)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a specific desired-state operation is still queued for a node.
|
||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
@@ -304,21 +387,29 @@ impl Db {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Queue `op`, returning its id and the staged blob of the op it replaced.
|
||||
///
|
||||
/// A rename a drain worker holds is not replaced but queued behind: the
|
||||
/// claim keeps the newer one from being sent until it retires, so the two
|
||||
/// land in the order they were made (`docs/BUGS.md` B141). Should it fail,
|
||||
/// [`Db::record_op_failure`] drops it.
|
||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
||||
let blob: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![op.uid, op.kind],
|
||||
"SELECT blob_path FROM pending_op
|
||||
WHERE uid = ?1 AND kind = ?2 AND (kind <> ?3 OR claimed_at = 0)",
|
||||
params![op.uid, op.kind, OP_RENAME],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
tx.execute(
|
||||
"DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![op.uid, op.kind],
|
||||
"DELETE FROM pending_op
|
||||
WHERE uid = ?1 AND kind = ?2 AND (kind <> ?3 OR claimed_at = 0)",
|
||||
params![op.uid, op.kind, OP_RENAME],
|
||||
)?;
|
||||
blob
|
||||
} else {
|
||||
@@ -429,21 +520,28 @@ impl Db {
|
||||
/// row names another parent or name than `landing.sent`, a rename of `real`
|
||||
/// to that target is queued in the same transaction, or the move would be
|
||||
/// lost with the row.
|
||||
///
|
||||
/// A delete while the create is on the wire cannot stop the upload. It
|
||||
/// turns the row into a trash ([`Db::delete_ops_for_uid`]), which is
|
||||
/// pointed at `real` here, still claimed by the caller, so the node is not
|
||||
/// left on Drive holding the name (`docs/BUGS.md` B129). A row gone
|
||||
/// altogether is queued as such a trash. The placeholder row is left to
|
||||
/// the delete that withdrew it.
|
||||
pub fn finish_create(
|
||||
&self,
|
||||
id: i64,
|
||||
uploaded: Option<&str>,
|
||||
landing: &CreateLanding<'_>,
|
||||
rewrite: impl FnOnce(&str) -> Option<String>,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
) -> Result<CreateRetired> {
|
||||
let (local, real) = (landing.local, landing.real);
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let row: Option<CreateRow> = tx
|
||||
.query_row(
|
||||
"SELECT blob_path, meta_json, parent_uid, name, created_at
|
||||
FROM pending_op WHERE id = ?1",
|
||||
params![id],
|
||||
FROM pending_op WHERE id = ?1 AND kind <> ?2",
|
||||
params![id, OP_TRASH],
|
||||
|r| {
|
||||
Ok(CreateRow {
|
||||
blob: r.get(0)?,
|
||||
@@ -455,6 +553,28 @@ impl Db {
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let withdrawn = tx.execute(
|
||||
"UPDATE pending_op SET uid = ?2, name = ?3 WHERE id = ?1 AND kind = ?4",
|
||||
params![id, real, landing.landed.1, OP_TRASH],
|
||||
)?;
|
||||
if withdrawn > 0 {
|
||||
tx.commit()?;
|
||||
return Ok(CreateRetired::Withdrawn { trash: id });
|
||||
}
|
||||
if row.is_none() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(1, |d| d.as_millis() as i64);
|
||||
tx.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, created_at, next_attempt_at, claimed_at)
|
||||
VALUES (?1, ?2, NULL, ?3, ?4, 0, ?4)",
|
||||
params![OP_TRASH, real, landing.landed.1, now],
|
||||
)?;
|
||||
let trash = tx.last_insert_rowid();
|
||||
tx.commit()?;
|
||||
return Ok(CreateRetired::Withdrawn { trash });
|
||||
}
|
||||
let newer = match &row {
|
||||
Some(CreateRow {
|
||||
blob: Some(blob),
|
||||
@@ -505,7 +625,10 @@ impl Db {
|
||||
)?;
|
||||
adopt_placeholder_row_tx(&tx, local, real)?;
|
||||
tx.commit()?;
|
||||
Ok(newer)
|
||||
Ok(match newer {
|
||||
Some((blob, meta)) => CreateRetired::Newer { blob, meta },
|
||||
None => CreateRetired::Landed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the sidecar of a queued op, for a baseline that has moved under
|
||||
@@ -539,12 +662,17 @@ impl Db {
|
||||
/// *is* the rename. Returns false when the create has already drained, in
|
||||
/// which case the node has a real uid and the caller must rename it there
|
||||
/// instead (offline.md Phase 3b).
|
||||
///
|
||||
/// A create backing off is made due: what it failed on may have been the
|
||||
/// name it no longer wants, such as one held by a queued change. A parked
|
||||
/// create stays parked.
|
||||
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let n = conn.execute(
|
||||
"UPDATE pending_op SET parent_uid = ?2, name = ?3
|
||||
"UPDATE pending_op SET parent_uid = ?2, name = ?3,
|
||||
next_attempt_at = CASE WHEN next_attempt_at < ?6 THEN 0 ELSE next_attempt_at END
|
||||
WHERE uid = ?1 AND kind IN (?4, ?5)",
|
||||
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR],
|
||||
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
@@ -978,8 +1106,22 @@ impl Db {
|
||||
/// Clears any access-deferral window: whatever the op was waiting for has
|
||||
/// now been reported, so the next uncleared deferral starts its own window
|
||||
/// rather than escalating again on the following recheck.
|
||||
///
|
||||
/// 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<()> {
|
||||
let conn = self.conn.lock();
|
||||
let dropped = conn.execute(
|
||||
"DELETE FROM pending_op
|
||||
WHERE id = ?1 AND kind = ?2
|
||||
AND EXISTS (SELECT 1 FROM pending_op AS newer
|
||||
WHERE newer.uid = pending_op.uid AND newer.kind = ?2
|
||||
AND newer.id > ?1)",
|
||||
params![id, OP_RENAME],
|
||||
)?;
|
||||
if dropped > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
|
||||
@@ -990,6 +1132,27 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make a create due again if it was renamed or moved
|
||||
/// ([`Db::rewrite_op_target`]) while the attempt that just failed was on
|
||||
/// the wire, `parent_uid` and `name` being what that attempt sent. What it
|
||||
/// failed on may have been the target it no longer has. Returns whether it
|
||||
/// was.
|
||||
pub fn retry_if_retargeted(
|
||||
&self,
|
||||
id: i64,
|
||||
parent_uid: Option<&str>,
|
||||
name: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let n = conn.execute(
|
||||
"UPDATE pending_op SET next_attempt_at = 0
|
||||
WHERE id = ?1 AND kind IN (?4, ?5) AND next_attempt_at < ?6
|
||||
AND (parent_uid IS NOT ?2 OR name IS NOT ?3)",
|
||||
params![id, parent_uid, name, OP_CREATE, OP_MKDIR, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Defer a due operation without recording a failed remote attempt.
|
||||
///
|
||||
/// Access checks happen before the drain calls Proton, so a locally denied
|
||||
|
||||
@@ -2474,6 +2474,125 @@ fn a_queued_trash_holds_its_name_until_it_lands() {
|
||||
assert!(!db.has_pending_trash_named("report.txt").unwrap());
|
||||
}
|
||||
|
||||
/// A create that found its name held by a queued trash backed off. The trash
|
||||
/// landing frees the name, so the create is due at once instead of after the
|
||||
/// backoff; a parked create stays parked.
|
||||
#[test]
|
||||
fn a_landed_trash_makes_the_ops_waiting_for_its_name_due() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let old = uid("old");
|
||||
db.upsert_node(&file("old", "root", "report.txt", 1))
|
||||
.unwrap();
|
||||
let (trash_id, _) = db
|
||||
.replace_ops_with_trash(&old.to_string(), "report.txt", 1)
|
||||
.unwrap();
|
||||
let create = |name: &str, next_attempt_at: i64| {
|
||||
let (id, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: format!("local~{name}-{next_attempt_at}"),
|
||||
parent_uid: Some(uid("root").to_string()),
|
||||
name: Some(name.to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 2,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at,
|
||||
})
|
||||
.unwrap();
|
||||
db.record_op_failure(id, "report.txt is still held", next_attempt_at)
|
||||
.unwrap();
|
||||
id
|
||||
};
|
||||
let waiting = create("report.txt", 9_000);
|
||||
let parked = create("report.txt", PARK_UNTIL);
|
||||
let other = create("other.txt", 9_000);
|
||||
|
||||
db.complete_trash_op(trash_id, &old).unwrap();
|
||||
|
||||
let due = |id| {
|
||||
db.pending_ops()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|op| op.id == id)
|
||||
.unwrap()
|
||||
.next_attempt_at
|
||||
};
|
||||
assert_eq!(due(waiting), 0);
|
||||
assert_eq!(due(parked), PARK_UNTIL);
|
||||
assert_eq!(due(other), 9_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_rename_away_from_a_name_holds_it_until_it_lands() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let root = uid("root").to_string();
|
||||
let rename = |node: &str, from: &str, to: &str| {
|
||||
let meta = serde_json::to_string(&RenameMeta {
|
||||
original_parent_uid: root.clone(),
|
||||
original_name: Some(from.to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_RENAME.to_string(),
|
||||
uid: uid(node).to_string(),
|
||||
parent_uid: Some(root.clone()),
|
||||
name: Some(to.to_string()),
|
||||
blob_path: None,
|
||||
meta_json: Some(meta),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let away = rename("away", "c.bin", "d");
|
||||
rename("back", "e.md", "e.md");
|
||||
let (waiting, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: "local~waiting".to_string(),
|
||||
parent_uid: Some(root.clone()),
|
||||
name: Some("c.bin".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 2,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
db.record_op_failure(waiting, "c.bin is still held", 9_000)
|
||||
.unwrap();
|
||||
|
||||
assert!(db.has_pending_move_from("c.bin").unwrap());
|
||||
assert!(!db.has_pending_move_from("d").unwrap());
|
||||
assert!(
|
||||
!db.has_pending_move_from("e.md").unwrap(),
|
||||
"a rename that keeps the name in its folder frees nothing"
|
||||
);
|
||||
db.wake_ops_waiting_for("c.bin").unwrap();
|
||||
let due = || {
|
||||
db.pending_ops()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|op| op.id == waiting)
|
||||
.unwrap()
|
||||
.next_attempt_at
|
||||
};
|
||||
assert_eq!(due(), 9_000, "the rename still holds the name");
|
||||
|
||||
db.delete_op(away).unwrap();
|
||||
db.wake_ops_waiting_for("c.bin").unwrap();
|
||||
assert_eq!(due(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -2796,6 +2915,106 @@ fn renaming_a_queued_create_rewrites_its_target() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A create that failed on a name held by a queued change waits out a
|
||||
/// backoff. Renamed away from that name, it is due again at once; parked, it
|
||||
/// stays parked.
|
||||
#[test]
|
||||
fn renaming_a_backing_off_create_makes_it_due() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let create = |local: &str| PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(uid("dir").to_string()),
|
||||
name: Some("a.txt".to_string()),
|
||||
blob_path: Some(format!("/staging/{local}")),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
let (failed, _) = db.enqueue_op(&create("local~failed")).unwrap();
|
||||
db.record_op_failure(failed, "a.txt is still held", 10_000)
|
||||
.unwrap();
|
||||
db.enqueue_op(&create("local~parked")).unwrap();
|
||||
db.set_create_hold("local~parked", true).unwrap();
|
||||
|
||||
db.rewrite_op_target("local~failed", &uid("dir").to_string(), "d")
|
||||
.unwrap();
|
||||
db.rewrite_op_target("local~parked", &uid("dir").to_string(), "e.tmp")
|
||||
.unwrap();
|
||||
|
||||
let ops = db.pending_ops().unwrap();
|
||||
let op = |local: &str| ops.iter().find(|o| o.uid == local).unwrap();
|
||||
assert_eq!(op("local~failed").next_attempt_at, 0);
|
||||
assert_eq!(op("local~failed").attempts, 1);
|
||||
assert_eq!(op("local~parked").next_attempt_at, PARK_UNTIL);
|
||||
|
||||
// Renamed while an attempt was on the wire, which then failed: the
|
||||
// failure is recorded after the rename, and the create is still due.
|
||||
let sent = op("local~failed").clone();
|
||||
db.rewrite_op_target("local~failed", &uid("dir").to_string(), "f")
|
||||
.unwrap();
|
||||
db.record_op_failure(sent.id, "d is still held", 10_000)
|
||||
.unwrap();
|
||||
assert!(
|
||||
db.retry_if_retargeted(sent.id, sent.parent_uid.as_deref(), sent.name.as_deref())
|
||||
.unwrap()
|
||||
);
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(
|
||||
ops.iter()
|
||||
.find(|o| o.id == sent.id)
|
||||
.unwrap()
|
||||
.next_attempt_at,
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
!db.retry_if_retargeted(sent.id, sent.parent_uid.as_deref(), Some("f"))
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// A rename queued while an older one is on the wire waits for it. The older
|
||||
/// one, if it fails, is dropped rather than retried after the newer one.
|
||||
#[test]
|
||||
fn a_rename_on_the_wire_lands_before_the_one_that_replaces_it() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let file = uid("file").to_string();
|
||||
let rename = |parent: &str| PendingOp {
|
||||
id: 0,
|
||||
kind: OP_RENAME.to_string(),
|
||||
uid: file.clone(),
|
||||
parent_uid: Some(uid(parent).to_string()),
|
||||
name: Some("e.md".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
let (first, _) = db.enqueue_op(&rename("x")).unwrap();
|
||||
assert_eq!(db.claim_next_due_op(1).unwrap().unwrap().id, first);
|
||||
|
||||
let (second, _) = db.enqueue_op(&rename("c0")).unwrap();
|
||||
assert!(db.claim_next_due_op(1).unwrap().is_none());
|
||||
|
||||
db.record_op_failure(first, "original hash is out of date", 10_000)
|
||||
.unwrap();
|
||||
db.release_op_claim(first).unwrap();
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.iter().map(|o| o.id).collect::<Vec<_>>(), [second]);
|
||||
assert_eq!(db.claim_next_due_op(1).unwrap().unwrap().id, second);
|
||||
|
||||
// Unclaimed, a queued rename is replaced as before.
|
||||
db.release_op_claim(second).unwrap();
|
||||
let (third, _) = db.enqueue_op(&rename("x")).unwrap();
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.iter().map(|o| o.id).collect::<Vec<_>>(), [third]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_folds_into_a_queued_create_instead_of_superseding_it() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -2885,10 +3104,10 @@ fn a_write_attached_while_its_create_uploads_becomes_a_revision() {
|
||||
|
||||
assert_eq!(
|
||||
newer,
|
||||
Some((
|
||||
"/staging/written".to_string(),
|
||||
"{\"uid\":\"vol~real\"}".to_string()
|
||||
))
|
||||
CreateRetired::Newer {
|
||||
blob: "/staging/written".to_string(),
|
||||
meta: "{\"uid\":\"vol~real\"}".to_string()
|
||||
}
|
||||
);
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1, "the write is still queued");
|
||||
@@ -2943,7 +3162,7 @@ fn a_create_whose_blob_landed_is_retired() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(newer, None);
|
||||
assert_eq!(newer, CreateRetired::Landed);
|
||||
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()));
|
||||
@@ -3102,6 +3321,60 @@ fn a_landed_folder_keeps_a_row_until_the_server_copy_replaces_it() {
|
||||
assert_eq!(found[0].path, "New folder/inside.txt");
|
||||
}
|
||||
|
||||
/// A file deleted while its create was on the wire still lands: nothing stops
|
||||
/// an upload in flight. The node it made is unwanted, so a trash of it is
|
||||
/// queued in the same transaction, already claimed by the drain that landed it
|
||||
/// (`docs/BUGS.md` B129).
|
||||
#[test]
|
||||
fn a_create_withdrawn_while_it_uploads_queues_a_trash_of_the_landed_node() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let local = "local~gone";
|
||||
let real = uid("real").to_string();
|
||||
let parent = uid("parent").to_string();
|
||||
let (id, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(parent.clone()),
|
||||
name: Some("d".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.claim_next_due_op(10)
|
||||
.unwrap()
|
||||
.expect("the drain takes the create");
|
||||
db.delete_ops_for_uid(local).unwrap();
|
||||
|
||||
let retired = db
|
||||
.finish_create(
|
||||
id,
|
||||
Some("/staging/sent"),
|
||||
&landing(local, &real, &parent, "d"),
|
||||
|_| None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let CreateRetired::Withdrawn { trash } = retired else {
|
||||
panic!("a withdrawn create is not landed: {retired:?}");
|
||||
};
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0].id, trash);
|
||||
assert_eq!(ops[0].kind, OP_TRASH);
|
||||
assert_eq!(ops[0].uid, real);
|
||||
assert_eq!(ops[0].name.as_deref(), Some("d"));
|
||||
assert!(
|
||||
db.claim_next_due_op(i64::MAX / 2).unwrap().is_none(),
|
||||
"the trash is the landing drain's to send"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_landed_placeholder_never_overwrites_the_server_copy() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -3194,6 +3467,7 @@ fn a_landed_placeholder_keeps_its_lid() {
|
||||
|
||||
assert_eq!(db.lid_of(&local.to_string()).unwrap(), None);
|
||||
assert_eq!(db.lid_of(&real.to_string()).unwrap(), Some(lid));
|
||||
assert_eq!(db.uid_of_lid(lid).unwrap(), Some(real.to_string()));
|
||||
let name = if known {
|
||||
"New folder (1)"
|
||||
} else {
|
||||
@@ -3207,6 +3481,85 @@ fn a_landed_placeholder_keeps_its_lid() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive lists none of what is queued for a folder, so a listing read from
|
||||
/// it is laid over with what the queue made, moved in and moved away (B132).
|
||||
#[test]
|
||||
fn queued_children_are_what_drive_does_not_list_yet() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("dir", Some("root"), "dir")).unwrap();
|
||||
db.upsert_node(&folder("other", Some("root"), "other"))
|
||||
.unwrap();
|
||||
let op = |kind: &str, uid: &str, parent: &str, name: &str| PendingOp {
|
||||
id: 0,
|
||||
kind: kind.to_string(),
|
||||
uid: uid.to_string(),
|
||||
parent_uid: Some(parent.to_string()),
|
||||
name: Some(name.to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
let dir = uid("dir").to_string();
|
||||
let other = uid("other").to_string();
|
||||
|
||||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("new"));
|
||||
let mut made = file("x", "dir", "made.txt", 3);
|
||||
made.uid = local.clone();
|
||||
db.upsert_node(&made).unwrap();
|
||||
db.enqueue_op(&op(OP_CREATE, &local.to_string(), &dir, "made.txt"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("in", "dir", "in.txt", 3)).unwrap();
|
||||
db.enqueue_op(&op(OP_RENAME, &uid("in").to_string(), &dir, "in.txt"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("out", "other", "out.txt", 3)).unwrap();
|
||||
db.enqueue_op(&op(OP_RENAME, &uid("out").to_string(), &other, "out.txt"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("still", "dir", "still.txt", 3))
|
||||
.unwrap();
|
||||
|
||||
let (here, gone) = db.queued_children(&uid("dir")).unwrap();
|
||||
let mut names: Vec<_> = here.iter().map(|n| n.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["in.txt", "made.txt"]);
|
||||
assert_eq!(
|
||||
gone,
|
||||
std::collections::HashSet::from([uid("out").to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// A restart builds the tree from the node each row holds. A folder that
|
||||
/// landed handed its children to the real uid in the row's column only, so
|
||||
/// they came back under a parent no row has (B140).
|
||||
#[test]
|
||||
fn a_landed_folder_hands_its_children_over_in_the_stored_node_too() {
|
||||
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("x", Some("root"), "dir");
|
||||
placeholder.uid = local.clone();
|
||||
db.upsert_node(&placeholder).unwrap();
|
||||
let mut child = file("child", "x", "a.txt", 3);
|
||||
child.parent_uid = Some(local.clone());
|
||||
db.upsert_node(&child).unwrap();
|
||||
|
||||
db.remap_local_uid(&local.to_string(), &uid("dir").to_string())
|
||||
.unwrap();
|
||||
|
||||
let stored = db.node_by_uid(&uid("child").to_string()).unwrap().unwrap();
|
||||
assert_eq!(stored.parent_uid, Some(uid("dir")));
|
||||
let names: Vec<_> = db
|
||||
.known_children(&uid("dir"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|n| n.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["a.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v36_gives_every_node_its_rowid_as_lid() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
|
||||
Reference in new issue
Block a user