Back the catalog up daily, not only before migrations
NFR-R2 asks for the catalog to be backed up on a schedule and before schema migrations. Only the second half existed: every backup on disk was a pre-migration copy, and a library that never migrated was never backed up at all. A backup is now also taken at the end of a library sweep when the newest one is more than a day old — the moment the catalog is quiet and a day's collection and people edits have just been folded in — on its own thread and its own connection, so the copy of a 130 MB file is not spent on the UI. Whether one is due is read from the backup directory, not the catalog, so the ordinary case costs nothing. An empty catalog is skipped: there is nothing in it a rescan would not rebuild. Pruning to KEEP_BACKUPS applies as before.
This commit is contained in:
@@ -198,6 +198,54 @@ pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a catalog may go without a backup before the next opportunity
|
||||
/// takes one.
|
||||
///
|
||||
/// A day. The catalog is an index, so what a backup protects is the day's
|
||||
/// worth of collection and people edits the sidecars do not hold — and a
|
||||
/// second copy of a 130 MB file per launch would be a cost with nothing to
|
||||
/// show for it when the user launches four times in an afternoon.
|
||||
pub const BACKUP_EVERY: i64 = 24 * 60 * 60;
|
||||
|
||||
/// Whether [`BACKUP_EVERY`] has passed since the newest backup, or there is
|
||||
/// none.
|
||||
///
|
||||
/// Read from the filenames, like [`backups`], so a restored or copied backup
|
||||
/// directory answers the same way it did on the machine it came from.
|
||||
pub fn backup_due(catalog: &Path) -> bool {
|
||||
match backups(catalog).first() {
|
||||
Some(newest) => now() - newest.taken_at >= BACKUP_EVERY,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: NFR-R2
|
||||
/// Take the scheduled backup, if one is due. Returns the file written, or
|
||||
/// `None` when the newest is recent enough.
|
||||
///
|
||||
/// The scheduled half of NFR-R2 — the migration half is
|
||||
/// [`backup_before_migration`]. "On a schedule" for an application that runs
|
||||
/// when the user opens it means "at the next chance after a day has passed",
|
||||
/// and the chance the caller picks is the end of a library sweep: the
|
||||
/// catalog is quiet, the work is already off the UI thread, and it is the
|
||||
/// moment a day's edits have just been consolidated.
|
||||
///
|
||||
/// A brand-new catalog with no images is not backed up: there is nothing in
|
||||
/// it yet that a rescan would not rebuild, and the first backup would only be
|
||||
/// a copy of an empty schema.
|
||||
pub fn backup_if_due(conn: &Connection, catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
||||
if !backup_due(catalog) {
|
||||
return Ok(None);
|
||||
}
|
||||
let images: i64 = conn.query_row("SELECT count(*) FROM images", [], |r| r.get(0))?;
|
||||
if images == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = backup(conn, catalog)?;
|
||||
log::info!("scheduled backup of the catalog to {}", path.display());
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
/// The backups available for `catalog`, newest first.
|
||||
///
|
||||
/// Never fails: an unreadable or absent backup directory means there are no
|
||||
@@ -386,6 +434,49 @@ mod tests {
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scheduled_backup_is_taken_once_a_day_and_not_more() {
|
||||
let dir = tempdir("scheduled");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 3);
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
|
||||
// Nothing yet: due.
|
||||
assert!(backup_due(&path));
|
||||
let first = backup_if_due(cat.connection(), &path).unwrap();
|
||||
assert!(first.is_some(), "the first opportunity takes one");
|
||||
|
||||
// Taken just now: not due, and a second call does nothing.
|
||||
assert!(!backup_due(&path));
|
||||
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
|
||||
assert_eq!(backups(&path).len(), 1);
|
||||
|
||||
// Age the one backup past the interval by renaming it, since the
|
||||
// timestamp is read from the name. Now it is due again.
|
||||
let old = first.unwrap();
|
||||
let aged = old
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(format!("catalog-{}.sqlite", now() - BACKUP_EVERY - 1));
|
||||
std::fs::rename(&old, &aged).unwrap();
|
||||
assert!(backup_due(&path));
|
||||
assert!(backup_if_due(cat.connection(), &path).unwrap().is_some());
|
||||
assert_eq!(backups(&path).len(), 2);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_catalog_is_not_worth_backing_up() {
|
||||
let dir = tempdir("empty");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
assert!(backup_due(&path), "due in principle");
|
||||
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
|
||||
assert!(backups(&path).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A catalog on disk with enough rows to span several pages, closed.
|
||||
///
|
||||
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
|
||||
|
||||
+19
-19
File diff suppressed because one or more lines are too long
@@ -4155,6 +4155,34 @@ fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
|
||||
refresh_timeline(window, catalog, ctl);
|
||||
}
|
||||
|
||||
/// TRACES: NFR-R2
|
||||
/// Take the day's catalog backup if one is due, off the UI thread.
|
||||
///
|
||||
/// Decided cheaply first — [`dr_catalog::recovery::backup_due`] reads a
|
||||
/// directory listing, not the catalog — so the ordinary case of "backed up
|
||||
/// this morning already" costs no thread and no connection.
|
||||
fn spawn_scheduled_backup(ctl: &Rc<LibraryController>) {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let catalog_path = library::catalog_path(&conn.account);
|
||||
if !dr_catalog::recovery::backup_due(&catalog_path) {
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let catalog = match dr_catalog::Catalog::open(&catalog_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("scheduled backup: opening the catalog: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = dr_catalog::recovery::backup_if_due(catalog.connection(), &catalog_path) {
|
||||
log::warn!("scheduled backup: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Push shards and the catalog to the server, and take what it has.
|
||||
///
|
||||
/// Fired after the sweep completes, when there is a finished index worth
|
||||
@@ -4445,6 +4473,14 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
refresh_timeline(&w, catalog, &ctl_cb);
|
||||
}
|
||||
}
|
||||
// TRACES: NFR-R2
|
||||
// The scheduled backup, at the moment the catalog is
|
||||
// quietest and a day's edits have just been folded in.
|
||||
// On its own thread, with its own connection: a copy
|
||||
// of a 130 MB file is a second or two the Slint loop
|
||||
// must not spend, and it runs beside the sync below,
|
||||
// which is only a reader of the same file.
|
||||
spawn_scheduled_backup(&ctl_cb);
|
||||
// Now that indexing is complete, hand the result to the
|
||||
// server so a second device inherits it rather than
|
||||
// repeating hours of range fetches.
|
||||
|
||||
Reference in New Issue
Block a user