feat(core,fuse,gui,cli): pause/resume sync and a visible upload queue (phase 2)

Backend (docs/UI_UX_PLAN.md B-1, B-2):
- Request::SetSyncPaused { paused, until } pauses the drain worker and the
  mirror reconcile. FUSE reads, write staging, the watcher and the poll keep
  running. The pause is persisted in the `state` table (`sync_paused_until`),
  so it survives a restart; a timed pause ends by itself through a timer
  thread, re-armed on mount. Resuming wakes the drain and runs a full
  reconcile. Response::Status gains `paused` and `paused_until`.
- Request::ListPendingOps lists the queue with paths, attempts, last error,
  next attempt and parked/failing flags. Request::RetryPendingOp { id } makes
  one backed-off op (or every failed one) due now. Parked ops are never
  un-parked and nothing is dropped from the queue.

Front ends:
- Sync page: a status card with Pause/Resume (split button with 1 h, 8 h and
  24 h pauses) and a "Waiting to Upload" list with Retry now and Retry All.
- The sidebar strip and the tray show the paused state; the tray gains Pause
  Sync / Resume Sync and names failing changes.
- CLI: `pdfs sync pause [--for 30m|1h|2d]`, `pdfs sync resume`,
  `pdfs sync queue`, `pdfs sync retry [id]`; `pdfs status` reports a pause.

Not in this phase: merging Activity into the Sync page, discarding a queued
op (needs a recovery copy first), and per-folder pause.
This commit is contained in:
nvrl committed 2026-09-23 02:06:41 +02:00
1 parent cf9e1665ca
commit 067561564e
17 files changed
+995 -13

No files matched your search

+2 -2
View File
@@ -56,8 +56,8 @@ pub use local::LocalFileHit;
pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
AttachedBlob, 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, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
+25
View File
@@ -772,6 +772,31 @@ impl Db {
Ok(())
}
/// Make one backed-off op due now, for the user's "Retry now". Parked ops are
/// left alone: they wait for a rename, not for time, and un-parking one would
/// upload a file its writer has not finished. Returns whether a row changed.
pub fn retry_op_now(&self, id: i64, now: i64) -> Result<bool> {
let conn = self.conn.lock();
let changed = conn.execute(
"UPDATE pending_op SET next_attempt_at = ?2
WHERE id = ?1 AND next_attempt_at > ?2 AND next_attempt_at < ?3",
params![id, now, PARK_UNTIL],
)?;
Ok(changed > 0)
}
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
/// least once. Returns how many rows changed.
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
let conn = self.conn.lock();
let changed = conn.execute(
"UPDATE pending_op SET next_attempt_at = ?1
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
params![now, PARK_UNTIL],
)?;
Ok(changed)
}
/// Record a failed attempt and when to next try. Leaves the row in place —
/// the staged bytes are still the only copy of the user's write.
///
+55
View File
@@ -4480,3 +4480,58 @@ fn migration_v31_treats_an_existing_timeline_as_resolved() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
}
fn queued_op(db: &Db, uid: &str, failures: i64, next_attempt_at: i64) -> i64 {
let (id, _) = db
.enqueue_op(&ops::PendingOp {
id: 0,
kind: ops::OP_REVISION.to_string(),
uid: uid.to_string(),
parent_uid: None,
name: None,
blob_path: None,
meta_json: None,
created_at: 1000,
attempts: 0,
last_error: None,
next_attempt_at,
})
.unwrap();
for _ in 0..failures {
db.record_op_failure(id, "boom", next_attempt_at).unwrap();
}
id
}
/// "Retry now" must make a backed-off op due, but never un-park a transient
/// file's op: that one waits for its finishing rename, not for time.
#[test]
fn retry_now_makes_a_backed_off_op_due_but_leaves_parked_ops_parked() {
let db = Db::open_in_memory().unwrap();
let backed_off = queued_op(&db, "vol~a", 3, 90_000);
let parked = queued_op(&db, "vol~b", 0, ops::PARK_UNTIL);
assert!(db.retry_op_now(backed_off, 5_000).unwrap());
assert!(!db.retry_op_now(parked, 5_000).unwrap());
let ops = db.pending_ops().unwrap();
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
assert_eq!(due(backed_off), 5_000);
assert_eq!(due(parked), ops::PARK_UNTIL);
}
#[test]
fn retry_all_touches_only_ops_that_have_failed() {
let db = Db::open_in_memory().unwrap();
let failed = queued_op(&db, "vol~a", 2, 90_000);
let debounced = queued_op(&db, "vol~b", 0, 7_000);
let parked = queued_op(&db, "vol~c", 4, ops::PARK_UNTIL);
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
let ops = db.pending_ops().unwrap();
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
assert_eq!(due(failed), 5_000);
assert_eq!(due(debounced), 7_000);
assert_eq!(due(parked), ops::PARK_UNTIL);
}