mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
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:
1 parent
cf9e1665ca
commit
067561564e
17 files changed
+995
-13
No files matched your search
@@ -246,6 +246,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
failing_error: queued.last_error.clone(),
|
||||
staged_bytes: staging.bytes,
|
||||
staged_oldest_secs: staging.oldest_secs,
|
||||
paused: core.sync_pause().is_some(),
|
||||
paused_until: core.sync_pause().flatten(),
|
||||
username: username.to_string(),
|
||||
mountpoint: mountpoint.display().to_string(),
|
||||
pinned: pins.len(),
|
||||
@@ -989,6 +991,32 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SetSyncPaused { paused, until }) => {
|
||||
match core.set_sync_paused(paused, until) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: match (paused, until) {
|
||||
(false, _) => "sync resumed".to_string(),
|
||||
(true, None) => "sync paused until resumed".to_string(),
|
||||
(true, Some(_)) => "sync paused".to_string(),
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||
Ok(items) => CtlResponse::PendingOps { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||
.to_string(),
|
||||
},
|
||||
Ok(moved) => CtlResponse::Ok {
|
||||
message: format!("retrying {moved} queued operation(s) now"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() {
|
||||
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
|
||||
@@ -285,8 +285,9 @@ impl Core {
|
||||
}
|
||||
};
|
||||
let online = self.online.load(Ordering::Relaxed);
|
||||
let paused = self.sync_paused();
|
||||
let op = match due {
|
||||
Some(op) if online => op,
|
||||
Some(op) if online && !paused => op,
|
||||
idle => {
|
||||
// Claimed but offline: hand it straight back, or it stays
|
||||
// invisible to every worker until this one happens to be
|
||||
@@ -306,6 +307,14 @@ impl Core {
|
||||
// its writer has to be noticed.
|
||||
self.sweep_parked_creates();
|
||||
}
|
||||
// Paused, nothing due changes that: sleep until a resume
|
||||
// wakes the worker (or the idle poll notices a timed pause
|
||||
// ran out), not until the next op falls due — that is
|
||||
// "now" for everything already queued.
|
||||
if paused {
|
||||
self.wait_for_drain_work();
|
||||
continue;
|
||||
}
|
||||
// A debounced or backed-off op may be waiting: sleep only
|
||||
// until it becomes due rather than the full idle-poll.
|
||||
self.wait_for_drain_work_or_due();
|
||||
|
||||
@@ -42,7 +42,7 @@ use std::os::unix::net::UnixListener;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fuser::ReplyXattr;
|
||||
@@ -88,8 +88,10 @@ mod drain;
|
||||
mod filesystem;
|
||||
pub use filesystem::ProtonFs;
|
||||
mod mount;
|
||||
mod pause;
|
||||
mod photos;
|
||||
mod profile;
|
||||
mod queue;
|
||||
mod reads;
|
||||
mod redate;
|
||||
mod revisions;
|
||||
@@ -508,6 +510,10 @@ struct Core {
|
||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||
/// infer it from a wall of EIO.
|
||||
online: Arc<AtomicBool>,
|
||||
/// When the user paused syncing, the Unix second it resumes by itself;
|
||||
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||
sync_paused_until: Arc<AtomicI64>,
|
||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||
/// which it is rebuilt on mount.
|
||||
|
||||
@@ -357,6 +357,7 @@ pub fn mount(
|
||||
// handlers can trigger reconciles.
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||
|
||||
let paused_until = pause::load_paused_until(&db);
|
||||
let core = Core {
|
||||
client: client.clone(),
|
||||
rt: rt.clone(),
|
||||
@@ -377,6 +378,7 @@ pub fn mount(
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
@@ -451,6 +453,10 @@ pub fn mount(
|
||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||
// A timed pause persisted by an earlier run still has to end on time.
|
||||
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||
core.arm_pause_timer(paused_until);
|
||||
}
|
||||
|
||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||
// out identical to the live file, surface the ones that genuinely diverge.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Pausing sync: the user's "stop uploading for a while".
|
||||
//!
|
||||
//! A pause holds back the two things that push local state to the remote — the
|
||||
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
|
||||
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
|
||||
//! is not a sync), writes keep being accepted and staged, and the watcher and
|
||||
//! poll keep noticing changes. Resuming lets all of it catch up at once.
|
||||
//!
|
||||
//! The pause is persisted in the database, so a restart does not quietly undo
|
||||
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
|
||||
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
|
||||
//! deadline that passed while the daemon was down is already over on mount.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The `state` key holding the resume time, in Unix seconds.
|
||||
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
|
||||
|
||||
/// A pause with no end time: it lasts until the user resumes.
|
||||
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
|
||||
|
||||
/// The persisted pause, or `0` when there is none (or it cannot be read — a
|
||||
/// broken state row must not keep sync paused forever).
|
||||
pub(crate) fn load_paused_until(db: &Db) -> i64 {
|
||||
match db.state_i64(PAUSED_UNTIL_KEY) {
|
||||
Ok(Some(until)) => until,
|
||||
Ok(None) => 0,
|
||||
Err(error) => {
|
||||
warn!(%error, "reading the sync pause failed; treating sync as running");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
|
||||
/// seconds). `0` is "not paused".
|
||||
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
|
||||
until != 0 && now < until
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// True while the user has sync paused.
|
||||
pub(crate) fn sync_paused(&self) -> bool {
|
||||
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
|
||||
}
|
||||
|
||||
/// The pause's end, for status reporting: `None` when not paused,
|
||||
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
|
||||
/// Unix second `t`.
|
||||
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
|
||||
let until = self.sync_paused_until.load(Ordering::Relaxed);
|
||||
match paused_at(until, now_secs()) {
|
||||
false => None,
|
||||
true if until == PAUSED_INDEFINITELY => Some(None),
|
||||
true => Some(Some(until)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
|
||||
/// it when `paused` is false.
|
||||
pub(crate) fn set_sync_paused(
|
||||
&self,
|
||||
paused: bool,
|
||||
until: Option<i64>,
|
||||
) -> Result<(), CoreError> {
|
||||
let value = match (paused, until) {
|
||||
(false, _) => 0,
|
||||
(true, None) => PAUSED_INDEFINITELY,
|
||||
(true, Some(until)) if until <= now_secs() => {
|
||||
return Err(CoreError::invalid("the pause would already be over"));
|
||||
}
|
||||
(true, Some(until)) => until,
|
||||
};
|
||||
let persisted = match value {
|
||||
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
|
||||
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
|
||||
};
|
||||
if let Err(error) = persisted {
|
||||
return Err(CoreError::internal(format!(
|
||||
"saving the sync pause: {error}"
|
||||
)));
|
||||
}
|
||||
self.sync_paused_until.store(value, Ordering::Relaxed);
|
||||
match value {
|
||||
0 => {
|
||||
info!("sync resumed");
|
||||
self.resume_sync_work();
|
||||
}
|
||||
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
|
||||
until => {
|
||||
info!(until, "sync paused");
|
||||
self.arm_pause_timer(until);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wake everything a pause held back: the drain worker, and a full
|
||||
/// reconcile to pick up whatever the watcher saw meanwhile.
|
||||
fn resume_sync_work(&self) {
|
||||
self.wake_drain();
|
||||
self.sync_now(None);
|
||||
}
|
||||
|
||||
/// End a timed pause when its time comes. A later pause or resume replaces
|
||||
/// the stored deadline, which the timer checks before acting, so a stale
|
||||
/// timer does nothing.
|
||||
pub(crate) fn arm_pause_timer(&self, until: i64) {
|
||||
let core = self.clone();
|
||||
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-pause".into())
|
||||
.spawn(move || {
|
||||
if !core.shutdown.sleep(wait) {
|
||||
return;
|
||||
}
|
||||
if core
|
||||
.sync_paused_until
|
||||
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
|
||||
warn!(%error, "clearing an expired sync pause failed");
|
||||
}
|
||||
info!("sync pause ended");
|
||||
core.resume_sync_work();
|
||||
}
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
// The drain and the reconcile still read the clock, so the pause
|
||||
// still ends — just at the next idle poll instead of on time.
|
||||
warn!(%error, "starting the sync pause timer failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pause_is_in_force_until_its_deadline() {
|
||||
assert!(!paused_at(0, 100));
|
||||
assert!(paused_at(200, 100));
|
||||
assert!(!paused_at(200, 200));
|
||||
assert!(!paused_at(200, 300));
|
||||
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The user's view of the pending-op queue: what has not reached the remote
|
||||
//! yet, why, and a way to stop waiting out a backoff.
|
||||
//!
|
||||
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
|
||||
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
||||
//! user's write (see `docs/RECOVERY.md`).
|
||||
|
||||
use super::*;
|
||||
use pdfs_core::control::PendingOpInfo;
|
||||
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||
|
||||
impl Core {
|
||||
/// Every queued op, oldest first, with a path a person can read.
|
||||
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||
Ok(ops
|
||||
.into_iter()
|
||||
.map(|op| {
|
||||
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||
let path = self
|
||||
.db
|
||||
.node_path(&op.uid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|path| !path.is_empty())
|
||||
.or_else(|| op.name.clone())
|
||||
.unwrap_or_else(|| op.uid.clone());
|
||||
PendingOpInfo {
|
||||
id: op.id,
|
||||
kind: op.kind,
|
||||
path,
|
||||
attempts: op.attempts,
|
||||
last_error: op.last_error,
|
||||
queued_at: op.created_at / 1000,
|
||||
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||
parked,
|
||||
failing: op.attempts >= FAILING_ATTEMPTS,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Retry one backed-off op now, or every failed one when `id` is `None`.
|
||||
/// Returns how many ops were moved up.
|
||||
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
|
||||
let now = now_millis();
|
||||
let moved = match id {
|
||||
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
|
||||
None => self.db.retry_failed_ops_now(now),
|
||||
}
|
||||
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
|
||||
if moved > 0 {
|
||||
self.wake_drain();
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
reconcile_all(&core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -151,6 +154,9 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -230,8 +236,14 @@ pub(super) fn classify(
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile every mirror folder in turn.
|
||||
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||
/// watcher reported meanwhile.
|
||||
fn reconcile_all(core: &Core) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
let folders = match core.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
|
||||
Reference in new issue
Block a user