mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
feat(core,fuse,cli,gui): report changes Drive refuses as sync issues
A change Drive refuses because the storage is full, access was taken away, its folder is gone, a folder limit is reached or the change was rejected used to look like any other failure until six attempts had passed. The drain now sorts the refusal into an issue and stores it on the queued op (schema 38), so it needs attention at once in the app, tray and CLI. The change stays queued and is retried; nothing is deleted. The queue listing carries the issue and whether the op has file content. ExportPendingOp copies that content to a new file: the Sync page offers it as an Export button next to each issue, and the CLI as `pdfs sync export <id> <dest>`. `pdfs sync issues` lists what needs attention, and `pdfs sync queue` shows the issue per change. docs/MILESTONE-3.0.0-STATUS.md tracks what is left for 3.0.0.
This commit is contained in:
1 parent
77d0f254fc
commit
a6f36d56f3
21 files changed
+2251
-1092
No files matched your search
@@ -477,6 +477,10 @@ pub enum Request {
|
||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||
RetryPendingOp { id: Option<i64> },
|
||||
/// Copy the content of a queued upload (`id` from
|
||||
/// [`Request::ListPendingOps`]) to `dest`, an absolute path that must not
|
||||
/// exist yet. The op stays queued. Replies with [`Response::Ok`].
|
||||
ExportPendingOp { id: i64, dest: String },
|
||||
/// List the `(sync-conflict …)` copies under My Files and in synced
|
||||
/// folders. Replies with
|
||||
/// [`Response::Conflicts`].
|
||||
@@ -880,6 +884,59 @@ pub struct PendingOpInfo {
|
||||
pub parked: bool,
|
||||
/// Failed often enough to count as stuck.
|
||||
pub failing: bool,
|
||||
/// Why Drive refused it, when it did; such an op needs attention at once.
|
||||
#[serde(default)]
|
||||
pub issue: Option<SyncIssue>,
|
||||
/// Carries file content that [`Request::ExportPendingOp`] can save.
|
||||
#[serde(default)]
|
||||
pub exportable: bool,
|
||||
}
|
||||
|
||||
/// Why Drive refused a queued change (in [`PendingOpInfo`]). The change stays
|
||||
/// queued and keeps being retried; nothing is deleted.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SyncIssue {
|
||||
/// The account's storage is full.
|
||||
Quota,
|
||||
/// The account may no longer change the file or its folder, for example
|
||||
/// because a share was revoked.
|
||||
Access,
|
||||
/// The file or the folder it goes into is gone from Drive.
|
||||
Missing,
|
||||
/// The folder holds too many items, or is nested too deep.
|
||||
Limit,
|
||||
/// Drive rejected the change, for example a name it does not accept.
|
||||
Rejected,
|
||||
/// An issue from a newer daemon.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SyncIssue {
|
||||
/// The name stored in the database and sent on the wire.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SyncIssue::Quota => "quota",
|
||||
SyncIssue::Access => "access",
|
||||
SyncIssue::Missing => "missing",
|
||||
SyncIssue::Limit => "limit",
|
||||
SyncIssue::Rejected => "rejected",
|
||||
SyncIssue::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// The issue stored as `name`.
|
||||
pub fn parse(name: &str) -> Self {
|
||||
match name {
|
||||
"quota" => SyncIssue::Quota,
|
||||
"access" => SyncIssue::Access,
|
||||
"missing" => SyncIssue::Missing,
|
||||
"limit" => SyncIssue::Limit,
|
||||
"rejected" => SyncIssue::Rejected,
|
||||
_ => SyncIssue::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
||||
@@ -3290,6 +3347,13 @@ mod tests {
|
||||
},
|
||||
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
||||
),
|
||||
(
|
||||
Request::ExportPendingOp {
|
||||
id: 7,
|
||||
dest: "/tmp/out.bin".into(),
|
||||
},
|
||||
r#"{"ExportPendingOp":{"id":7,"dest":"/tmp/out.bin"}}"#,
|
||||
),
|
||||
];
|
||||
for (request, wire) in cases {
|
||||
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
||||
@@ -3298,6 +3362,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A daemon that predates sync issues sends neither field, and a newer one
|
||||
/// may send an issue this build does not know.
|
||||
#[test]
|
||||
fn a_queued_op_from_another_daemon_version_still_decodes() {
|
||||
let old = r#"{"id":1,"kind":"create","path":"a.txt","attempts":0,"last_error":null,
|
||||
"queued_at":5,"next_attempt_at":5,"parked":false,"failing":false}"#;
|
||||
let op: PendingOpInfo = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(op.issue, None);
|
||||
assert!(!op.exportable);
|
||||
|
||||
let newer = old.replace(r#""failing":false"#, r#""failing":true,"issue":"embargo""#);
|
||||
let op: PendingOpInfo = serde_json::from_str(&newer).unwrap();
|
||||
assert_eq!(op.issue, Some(SyncIssue::Unknown));
|
||||
assert_eq!(
|
||||
SyncIssue::parse(SyncIssue::Quota.as_str()),
|
||||
SyncIssue::Quota
|
||||
);
|
||||
}
|
||||
|
||||
/// A front-end that predates the favorites filter sends no `favorites`
|
||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||
#[test]
|
||||
|
||||
@@ -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 = 37;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 38;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -363,6 +363,22 @@ impl Db {
|
||||
tx.execute_batch(MIGRATION_V37)?;
|
||||
}
|
||||
}
|
||||
if current < 38 {
|
||||
// Same guards as V26-V37.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'issue'",
|
||||
[],
|
||||
|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_V38)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -1164,3 +1180,9 @@ CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||
CREATE INDEX idx_pending_op_claimed
|
||||
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||
";
|
||||
|
||||
/// Schema v38: why Drive refused a queued op, when it did (a sync issue).
|
||||
/// `NULL` for an op that has not been refused, which is every op migrated.
|
||||
const MIGRATION_V38: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN issue TEXT;
|
||||
";
|
||||
@@ -2,6 +2,8 @@
|
||||
//! the remote has not yet seen. Rebuilt into memory on mount and drained in row
|
||||
//! order, so a child never drains before the parent that gives it a real uid.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -257,7 +259,8 @@ pub struct PendingCounts {
|
||||
/// never drain on their own, so counting them as "waiting to upload" would
|
||||
/// have the front-end promise progress that is not coming.
|
||||
pub parked: i64,
|
||||
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times. A permanently
|
||||
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times, or that Drive
|
||||
/// refused ([`Db::set_op_issue`]). A permanently
|
||||
/// failing op never wedges the queue — the backoff sees to that — which is
|
||||
/// exactly why it needs surfacing: it retries forever, invisibly.
|
||||
pub failing: i64,
|
||||
@@ -1028,15 +1031,15 @@ impl Db {
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let failing = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1",
|
||||
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1 OR issue IS NOT NULL",
|
||||
params![FAILING_ATTEMPTS],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let last_error = conn
|
||||
.query_row(
|
||||
"SELECT last_error FROM pending_op
|
||||
WHERE attempts >= ?1 AND last_error IS NOT NULL
|
||||
ORDER BY attempts DESC LIMIT 1",
|
||||
WHERE (attempts >= ?1 OR issue IS NOT NULL) AND last_error IS NOT NULL
|
||||
ORDER BY issue IS NULL, attempts DESC LIMIT 1",
|
||||
params![FAILING_ATTEMPTS],
|
||||
|r| r.get::<_, Option<String>>(0),
|
||||
)
|
||||
@@ -1132,6 +1135,26 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record why Drive refused op `id`, or that the latest failure was not a
|
||||
/// refusal (`None`). An op with an issue counts as failing at once.
|
||||
pub fn set_op_issue(&self, id: i64, issue: Option<&str>) -> Result<()> {
|
||||
self.conn.lock().execute(
|
||||
"UPDATE pending_op SET issue = ?2 WHERE id = ?1",
|
||||
params![id, issue],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The issue of every op Drive refused, by op id.
|
||||
pub fn op_issues(&self) -> Result<HashMap<i64, String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT id, issue FROM pending_op WHERE issue IS NOT NULL")?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -6138,6 +6138,33 @@ fn retry_now_makes_a_backed_off_op_due_but_leaves_parked_ops_parked() {
|
||||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||
}
|
||||
|
||||
/// A change Drive refused needs the user now, not after six more failures, and
|
||||
/// its refusal is the error the status leads with.
|
||||
#[test]
|
||||
fn an_op_with_an_issue_counts_as_failing_at_once() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
queued_op(&db, "vol~a", 1, 90_000);
|
||||
let refused = queued_op(&db, "vol~b", 1, 90_000);
|
||||
assert_eq!(db.pending_op_counts().unwrap().failing, 0);
|
||||
|
||||
db.record_op_failure(refused, "storage quota exceeded", 90_000)
|
||||
.unwrap();
|
||||
db.set_op_issue(refused, Some("quota")).unwrap();
|
||||
let counts = db.pending_op_counts().unwrap();
|
||||
assert_eq!(counts.failing, 1);
|
||||
assert_eq!(counts.last_error.as_deref(), Some("storage quota exceeded"));
|
||||
assert_eq!(
|
||||
db.op_issues().unwrap(),
|
||||
std::collections::HashMap::from([(refused, "quota".to_string())])
|
||||
);
|
||||
|
||||
// The op stays queued with its bytes; clearing the issue is all it takes.
|
||||
db.set_op_issue(refused, None).unwrap();
|
||||
assert_eq!(db.pending_op_counts().unwrap().failing, 0);
|
||||
assert!(db.op_issues().unwrap().is_empty());
|
||||
assert_eq!(db.pending_ops().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_all_touches_only_ops_that_have_failed() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
Reference in new issue
Block a user