Catalog::open ran schema::backfill every time, and every worker thread opens its own connection. A develop landing made five opens, and each paid the RAW/JPEG pairing, the default-version anti-join over every image, the uuid pass over every default version and the keyword check: 17 ms of CPU an open on a copy of the reference catalog, ~80 ms a landing, to confirm that nothing had changed since the open before. Everything the backfill repairs is a row some write added: an image a scan inserted, a version or keyword assignment a merge brought in. So the open now reads a stamp - user_version, max(id) of images and versions, max(rowid) of keywords, and the file's device and inode - and skips the backfill when the stamp matches the one recorded at this path's last backfill in this process. The maxima are each the last page of a b-tree; an open that skips costs ~1 ms. The backfill still runs: - on the first open in a process (nothing recorded yet); - on any open that migrated the schema, unconditionally; - after a pull: merge_remote forgets the path, so the next open backfills even when every incoming row collided and nothing moved; - when the file is replaced under its name: the inode is in the stamp, and recovery::set_aside, the first step of a restore and a rebuild, forgets the path; - when another process or thread adds rows, because the stamp is read from the file, not from anything this process did. The stamp is taken before the backfill, not after. Read after, it would describe the backfill's own inserts, and could record an image another connection inserted in between as covered when it was not. Read before, the worst case is one redundant pass after a backfill that did real work. Kept in memory rather than in the catalog: a stamp row would need a table an older build does not have and would travel in the sync snapshot, where a flag from another device's catalog says nothing about this one. No schema version bump, so the tablet on 0.16.0 still reads the snapshot. Tests cover the skip, a scan's new image, a migration, a pull and a replaced file.
758 lines
30 KiB
Rust
758 lines
30 KiB
Rust
//! TRACES: NFR-R2 | NFR-R6
|
|
//! What to do once the index is already damaged.
|
|
//!
|
|
//! # Why this can be a small module
|
|
//!
|
|
//! Because of a property the rest of the catalog was built to keep: the
|
|
//! catalog is an *index*, not a source of truth (ARCH §6.12, invariant
|
|
//! §5.2.4). Sidecars beside the images hold the authoritative ratings,
|
|
//! keywords and edit graphs, for every catalogued image and whether or not a
|
|
//! remote account exists (FR-CAT-8). So the worst outcome available here is a
|
|
//! rescan — expensive, but not a loss.
|
|
//!
|
|
//! That is the second offer. The first is cheaper and loses nothing at all: a
|
|
//! backup, restored.
|
|
//!
|
|
//! # The one thing a rebuild does not recover
|
|
//!
|
|
//! **Collections.** A manual collection is a set of images the user assembled
|
|
//! by hand and nothing in the filesystem records it (`docs/dev/catalog.md` §8.1) —
|
|
//! which is the whole reason the catalog file itself syncs. So the two offers
|
|
//! are not interchangeable, and the interface must not present them as if they
|
|
//! were: a restore keeps the user's collections, a rebuild does not.
|
|
//!
|
|
//! # When the check runs, and when it does not
|
|
//!
|
|
//! [`integrity_check`] reads every page of the database. That is affordable
|
|
//! once, at startup, where a failure has a user in front of it who can answer
|
|
//! a question — and it is *not* affordable on every [`Catalog::open`], which
|
|
//! this application does per background task, dozens of times a session. So
|
|
//! the check is bound to [`Catalog::open_verified`] rather than to `open`,
|
|
//! and the cheap half of the story — classifying `SQLITE_CORRUPT` and
|
|
//! `SQLITE_NOTADB` as [`CatalogError::Corrupt`] — happens for free on every
|
|
//! query through [`crate::error`]'s conversion. A background job that trips
|
|
//! over the damage first therefore reports the same thing the startup check
|
|
//! would have.
|
|
//!
|
|
//! [`Catalog::open`]: crate::Catalog::open
|
|
//! [`Catalog::open_verified`]: crate::Catalog::open_verified
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use rusqlite::Connection;
|
|
|
|
use crate::error::CatalogError;
|
|
use crate::schema;
|
|
|
|
/// Directory backups live in, relative to the catalog file.
|
|
///
|
|
/// Beside the catalog rather than in the cache directory, and that is the
|
|
/// point of the choice: this is the copy the user falls back on, and a cache
|
|
/// is a place the operating system is entitled to empty without asking
|
|
/// (see `library::data_root` for the same reasoning about sidecars).
|
|
const BACKUP_DIR: &str = "backups";
|
|
|
|
/// How many backups are kept.
|
|
///
|
|
/// Small on purpose. A backup is a full copy of a catalog that is tens of
|
|
/// megabytes at 50k images, and the value of the third-oldest one is close to
|
|
/// zero: corruption is noticed at the next launch, not months later. What the
|
|
/// depth buys is protection against backing *up* the damage — if a corrupt
|
|
/// catalog is copied before anyone notices, the generation behind it is still
|
|
/// clean.
|
|
pub const KEEP_BACKUPS: usize = 3;
|
|
|
|
/// Suffix given to a catalog that has been set aside as damaged.
|
|
///
|
|
/// Kept rather than deleted. It costs disk this application would rather not
|
|
/// spend, and it is still the right call: `.sqlite` files have been recovered
|
|
/// by hand before, the user has not consented to a deletion, and NFR-R4's
|
|
/// instinct — never destroy what the user did not ask you to destroy — does
|
|
/// not stop applying at the catalog's edge.
|
|
const DAMAGED_SUFFIX: &str = "damaged";
|
|
|
|
/// One kept backup.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Backup {
|
|
pub path: PathBuf,
|
|
/// UTC seconds at which it was taken, read from the filename rather than
|
|
/// from the filesystem: a copy, a restore or a sync can rewrite an mtime,
|
|
/// and then the newest backup is not the one that looks newest.
|
|
pub taken_at: i64,
|
|
pub bytes: u64,
|
|
}
|
|
|
|
/// Where backups for `catalog` are kept.
|
|
pub fn backup_dir(catalog: &Path) -> PathBuf {
|
|
catalog
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("."))
|
|
.join(BACKUP_DIR)
|
|
}
|
|
|
|
/// Check the database this connection is attached to.
|
|
///
|
|
/// `quick_check` rather than `integrity_check`: the difference is that
|
|
/// `quick_check` skips verifying that every index agrees with its table, which
|
|
/// is the expensive half and the half this application least needs — every
|
|
/// index here is derivable, and `REINDEX` fixes one without anybody being
|
|
/// asked a question. What is left still reads every page, and catches the
|
|
/// damage that matters: torn b-trees, a bad freelist, a truncated file.
|
|
///
|
|
/// Returns [`CatalogError::Corrupt`] carrying what SQLite said, so the message
|
|
/// the user sees is the diagnosis rather than a paraphrase of it.
|
|
pub fn integrity_check(conn: &Connection) -> Result<(), CatalogError> {
|
|
// The argument caps how many problems are reported. One is enough: the
|
|
// answer is the same whether the file has one damaged page or nine
|
|
// hundred, and an unbounded check on a badly damaged file can run for a
|
|
// very long time producing a list nobody will read.
|
|
let mut stmt = conn.prepare("PRAGMA quick_check(1)")?;
|
|
let rows: Vec<String> = stmt
|
|
.query_map([], |r| r.get(0))?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
// A healthy database answers with the single row "ok".
|
|
if rows.len() == 1 && rows[0] == "ok" {
|
|
return Ok(());
|
|
}
|
|
Err(CatalogError::Corrupt {
|
|
detail: rows.join("; "),
|
|
})
|
|
}
|
|
|
|
/// Check a catalog file that is not currently open.
|
|
///
|
|
/// Used before a restore: a backup is only worth swapping in if it is sound,
|
|
/// and swapping in a second damaged file — leaving the user with no catalog
|
|
/// and no offer left — is the failure this exists to prevent.
|
|
pub fn check_file(path: &Path) -> Result<(), CatalogError> {
|
|
if !path.is_file() {
|
|
return Err(CatalogError::Io(format!("{} is missing", path.display())));
|
|
}
|
|
// Read-write rather than read-only, which reads oddly for a check. A
|
|
// backup carries the WAL journal mode in its header because it was copied
|
|
// page-for-page from a WAL database, and SQLite cannot open one read-only
|
|
// without a shared-memory file it is then not allowed to create. Nothing
|
|
// here writes; the connection is opened, read, and dropped.
|
|
let conn = Connection::open(path)?;
|
|
integrity_check(&conn)
|
|
}
|
|
|
|
/// Take a backup of the open catalog.
|
|
///
|
|
/// Returns the file written. Older generations beyond [`KEEP_BACKUPS`] are
|
|
/// pruned, newest kept.
|
|
///
|
|
/// Goes through `crate::sync::copy_to` — SQLite's own backup API after a
|
|
/// TRUNCATE checkpoint — rather than copying the file. A WAL database is not
|
|
/// one file, and `fs::copy` of the main file alone would silently back up a
|
|
/// state that is older than the catalog and possibly torn, which is the one
|
|
/// failure mode a backup cannot afford.
|
|
pub fn backup(conn: &Connection, catalog: &Path) -> Result<PathBuf, CatalogError> {
|
|
let dir = backup_dir(catalog);
|
|
std::fs::create_dir_all(&dir)
|
|
.map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?;
|
|
|
|
let dest = dir.join(format!("catalog-{}.sqlite", now()));
|
|
// A second backup within the same second would otherwise land on the first
|
|
// one's name. Rare, and only reachable from tests and a retry, but the
|
|
// result would be a half-overwritten backup rather than two.
|
|
if dest.exists() {
|
|
std::fs::remove_file(&dest)
|
|
.map_err(|e| CatalogError::Io(format!("replacing {}: {e}", dest.display())))?;
|
|
}
|
|
|
|
// Dropped immediately: the copy is complete when `copy_to` returns, and
|
|
// holding the connection open would leave a `-wal` beside a file whose
|
|
// whole purpose is to be a single self-contained artefact.
|
|
drop(crate::sync::copy_to(conn, &dest)?);
|
|
|
|
prune(catalog);
|
|
Ok(dest)
|
|
}
|
|
|
|
/// Back up before a migration, if there is anything to back up.
|
|
///
|
|
/// Called from [`Catalog::open`](crate::Catalog::open) between `configure` and
|
|
/// `migrate`. NFR-R2 asks for this and the reasoning is narrower than "backups
|
|
/// are prudent": a migration is the one routine operation that rewrites table
|
|
/// structure, so it is the likeliest way a catalog becomes unreadable, and it
|
|
/// is the one moment where the pre-change state is still on disk to be copied.
|
|
/// Afterwards there is nothing left to take a copy *of*.
|
|
///
|
|
/// A no-op in the two cases where it would cost without buying anything: a
|
|
/// catalog already at [`schema::SCHEMA_VERSION`], and a brand-new file at
|
|
/// version 0 with no tables in it yet.
|
|
pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(), CatalogError> {
|
|
let from: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
|
if from == 0 || from >= schema::SCHEMA_VERSION {
|
|
return Ok(());
|
|
}
|
|
let path = backup(conn, catalog)?;
|
|
log::info!(
|
|
"backed up catalog at v{from} to {} before migrating to v{}",
|
|
path.display(),
|
|
schema::SCHEMA_VERSION
|
|
);
|
|
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
|
|
/// backups, which is a fact about the offer to make rather than an error to
|
|
/// report on top of the corruption the user is already looking at.
|
|
pub fn backups(catalog: &Path) -> Vec<Backup> {
|
|
let dir = backup_dir(catalog);
|
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut out: Vec<Backup> = entries
|
|
.flatten()
|
|
.filter_map(|e| {
|
|
let path = e.path();
|
|
let taken_at = timestamp_of(&path)?;
|
|
let bytes = e.metadata().ok()?.len();
|
|
Some(Backup {
|
|
path,
|
|
taken_at,
|
|
bytes,
|
|
})
|
|
})
|
|
.collect();
|
|
out.sort_by_key(|b| std::cmp::Reverse(b.taken_at));
|
|
out
|
|
}
|
|
|
|
/// Put a backup back in place of the damaged catalog.
|
|
///
|
|
/// **Every connection to `catalog` must be closed first.** This replaces the
|
|
/// file underneath anything still holding it open, which on a live connection
|
|
/// is how a *second* corrupt catalog gets made.
|
|
///
|
|
/// The order is deliberate:
|
|
///
|
|
/// 1. The backup is checked. A restore that installs a second damaged file
|
|
/// leaves the user with nothing to try next.
|
|
/// 2. The damaged catalog is renamed aside, and its `-wal` and `-shm` are
|
|
/// **deleted**. This is the step that is easy to leave out and fatal to
|
|
/// leave out: a journal belonging to the old file, sitting beside the new
|
|
/// one under the same name, is replayed into it on the next open. That is
|
|
/// not a restore, it is a fresh corruption with the evidence gone.
|
|
/// 3. The backup is *copied* into place, not moved, so a failure here can be
|
|
/// retried against the same backup.
|
|
pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> {
|
|
check_file(backup)?;
|
|
set_aside(catalog)?;
|
|
std::fs::copy(backup, catalog).map_err(|e| {
|
|
CatalogError::Io(format!(
|
|
"restoring {} from {}: {e}",
|
|
catalog.display(),
|
|
backup.display()
|
|
))
|
|
})?;
|
|
log::info!(
|
|
"restored {} from backup {}",
|
|
catalog.display(),
|
|
backup.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Move a damaged catalog out of the way so the next open builds a fresh one.
|
|
///
|
|
/// This is the rebuild path (NFR-R6's second offer) and also the first half of
|
|
/// a [`restore`]. Nothing else is needed to rebuild: the next
|
|
/// [`Catalog::open`](crate::Catalog::open) creates an empty catalog at the
|
|
/// current schema, and the ordinary scan repopulates it from sources and
|
|
/// sidecars — which is precisely invariant §5.2.4 being spent rather than
|
|
/// merely asserted.
|
|
///
|
|
/// Returns where the damaged file was put, or `None` if there was no catalog
|
|
/// to move — a caller may be recovering from a file SQLite could not open
|
|
/// because it was never created.
|
|
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
|
// Whatever takes this name next — a rebuild or a restored backup — is not
|
|
// the file this process last backfilled. Forgotten while the path still
|
|
// resolves, so it is the same key the open recorded.
|
|
crate::backfilled::forget(catalog);
|
|
let moved = if catalog.exists() {
|
|
let dest = with_suffix(catalog, DAMAGED_SUFFIX);
|
|
// An earlier damaged copy is replaced rather than accumulating: two of
|
|
// these is two full-size catalogs on the user's disk, and the older
|
|
// one has already been superseded by a recovery the user completed.
|
|
let _ = std::fs::remove_file(&dest);
|
|
// The rename first, so that a failure here leaves the journals with
|
|
// the file they belong to rather than orphaned beside a catalog that
|
|
// is still in use.
|
|
std::fs::rename(catalog, &dest).map_err(|e| {
|
|
CatalogError::Io(format!(
|
|
"setting aside {} as {}: {e}",
|
|
catalog.display(),
|
|
dest.display()
|
|
))
|
|
})?;
|
|
log::warn!(
|
|
"catalog {} was damaged; kept as {}",
|
|
catalog.display(),
|
|
dest.display()
|
|
);
|
|
Some(dest)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Then the journals, whether or not there was a catalog to move: a `-wal`
|
|
// orphaned beside a missing database is replayed into whatever takes that
|
|
// name next, which would not be a restore but a fresh corruption with the
|
|
// evidence gone.
|
|
for sidecar in journals(catalog) {
|
|
if let Err(e) = std::fs::remove_file(&sidecar) {
|
|
if e.kind() != std::io::ErrorKind::NotFound {
|
|
return Err(CatalogError::Io(format!(
|
|
"removing stale journal {}: {e}",
|
|
sidecar.display()
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(moved)
|
|
}
|
|
|
|
/// Delete backups beyond [`KEEP_BACKUPS`].
|
|
///
|
|
/// Best-effort and silent about individual failures: failing to delete an old
|
|
/// backup is not a reason to fail the new one, which is already written.
|
|
fn prune(catalog: &Path) {
|
|
for old in backups(catalog).into_iter().skip(KEEP_BACKUPS) {
|
|
if let Err(e) = std::fs::remove_file(&old.path) {
|
|
log::warn!("could not prune backup {}: {e}", old.path.display());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The WAL and shared-memory files SQLite keeps beside a database.
|
|
fn journals(catalog: &Path) -> [PathBuf; 2] {
|
|
[with_suffix(catalog, "wal"), with_suffix(catalog, "shm")]
|
|
}
|
|
|
|
/// `catalog.sqlite` plus `-suffix`, the way SQLite names its own sidecars.
|
|
///
|
|
/// Appended to the whole filename rather than replacing the extension, so
|
|
/// `catalog.sqlite-wal` is what SQLite would look for and `catalog.sqlite-
|
|
/// damaged` sorts next to the catalog it came from.
|
|
fn with_suffix(catalog: &Path, suffix: &str) -> PathBuf {
|
|
let mut s = catalog.as_os_str().to_os_string();
|
|
s.push("-");
|
|
s.push(suffix);
|
|
PathBuf::from(s)
|
|
}
|
|
|
|
/// Read the timestamp out of a backup's filename, or `None` if this is not one.
|
|
///
|
|
/// Doubles as the filter that keeps [`backups`] from offering the user
|
|
/// something that is not a catalog — a stray file in the directory, or a `-wal`
|
|
/// left by a crash mid-backup.
|
|
fn timestamp_of(path: &Path) -> Option<i64> {
|
|
let name = path.file_name()?.to_str()?;
|
|
name.strip_prefix("catalog-")?
|
|
.strip_suffix(".sqlite")?
|
|
.parse()
|
|
.ok()
|
|
}
|
|
|
|
/// Seconds since the epoch, or 0 if the clock is before it.
|
|
fn now() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::Catalog;
|
|
use std::io::{Seek, SeekFrom, Write};
|
|
|
|
/// A scratch directory that cleans up with the test.
|
|
fn tempdir(tag: &str) -> PathBuf {
|
|
let base = std::env::temp_dir().join(format!(
|
|
"dr-recovery-{tag}-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
std::fs::create_dir_all(&base).unwrap();
|
|
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
|
|
/// something checkpoints, and a test that corrupted the main file while
|
|
/// the data was still in the journal would be corrupting empty space.
|
|
fn fixture(path: &Path, images: i64) {
|
|
let cat = Catalog::open(path).unwrap();
|
|
let c = cat.connection();
|
|
c.execute(
|
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
c.execute(
|
|
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
|
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for i in 1..=images {
|
|
c.execute(
|
|
"INSERT INTO images(id, root_id, source_ref, added_at)
|
|
VALUES (?1, 1, ?2, 0)",
|
|
rusqlite::params![i, format!("DCIM/IMG_{i:05}.CR3")],
|
|
)
|
|
.unwrap();
|
|
}
|
|
crate::sync::checkpoint(c).unwrap();
|
|
drop(cat);
|
|
}
|
|
|
|
/// Scribble over everything past the first two pages.
|
|
///
|
|
/// Past them rather than over them so that page 1 — the header and the
|
|
/// schema — survives: this produces a file SQLite is willing to open and
|
|
/// then finds damaged, which is the case `quick_check` exists for. Wiping
|
|
/// the header instead produces `SQLITE_NOTADB` at the first pragma, which
|
|
/// is a different branch and has its own test.
|
|
fn corrupt(path: &Path) {
|
|
let mut f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
|
let len = f.metadata().unwrap().len();
|
|
assert!(
|
|
len > 8192,
|
|
"fixture is only {len} bytes; corrupting past page 2 would be a no-op"
|
|
);
|
|
let junk = vec![0x5a_u8; (len - 8192) as usize];
|
|
f.seek(SeekFrom::Start(8192)).unwrap();
|
|
f.write_all(&junk).unwrap();
|
|
f.sync_all().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_healthy_catalog_passes() {
|
|
let cat = Catalog::in_memory().unwrap();
|
|
integrity_check(cat.connection()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_catalog_is_reported_as_corrupt_not_as_sqlite() {
|
|
// The whole point of the variant: this used to arrive as whatever
|
|
// rusqlite error the first failing query produced, with nowhere to
|
|
// hang a recovery offer.
|
|
let dir = tempdir("detect");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 500);
|
|
corrupt(&path);
|
|
|
|
assert!(matches!(
|
|
Catalog::open_verified(&path),
|
|
Err(CatalogError::Corrupt { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_that_is_not_a_database_is_also_corrupt() {
|
|
// A truncated or overwritten catalog never reaches `quick_check`: the
|
|
// first pragma fails with SQLITE_NOTADB. Same accident to the user,
|
|
// same two offers, so it must classify the same way.
|
|
let dir = tempdir("notadb");
|
|
let path = dir.join("catalog.sqlite");
|
|
std::fs::write(&path, b"this is not a catalog, it is a text file\n").unwrap();
|
|
|
|
assert!(matches!(
|
|
Catalog::open(&path),
|
|
Err(CatalogError::Corrupt { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn restoring_a_backup_recovers_the_collections_a_rebuild_would_lose() {
|
|
// The first NFR-R6 branch, asserted on the thing that distinguishes it
|
|
// from the second: a collection exists nowhere but the catalog, so it
|
|
// is the evidence that the *contents* came back and not merely a
|
|
// readable file (docs/dev/catalog.md §8.1).
|
|
let dir = tempdir("restore");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 500);
|
|
|
|
{
|
|
let cat = Catalog::open(&path).unwrap();
|
|
backup(cat.connection(), &path).unwrap();
|
|
}
|
|
corrupt(&path);
|
|
assert!(matches!(
|
|
Catalog::open_verified(&path),
|
|
Err(CatalogError::Corrupt { .. })
|
|
));
|
|
|
|
let newest = backups(&path).into_iter().next().expect("a backup exists");
|
|
restore(&path, &newest.path).unwrap();
|
|
|
|
let cat = Catalog::open_verified(&path).unwrap();
|
|
let name: String = cat
|
|
.connection()
|
|
.query_row("SELECT name FROM collections", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(name, "Iceland");
|
|
let images: i64 = cat
|
|
.connection()
|
|
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(images, 500);
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_backup_is_refused_rather_than_installed() {
|
|
let dir = tempdir("badbackup");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 500);
|
|
{
|
|
let cat = Catalog::open(&path).unwrap();
|
|
backup(cat.connection(), &path).unwrap();
|
|
}
|
|
let newest = backups(&path).into_iter().next().unwrap();
|
|
corrupt(&newest.path);
|
|
corrupt(&path);
|
|
|
|
assert!(matches!(
|
|
restore(&path, &newest.path),
|
|
Err(CatalogError::Corrupt { .. })
|
|
));
|
|
// And the damaged catalog is still where it was, so the second offer
|
|
// is still available.
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn setting_aside_leaves_a_fresh_catalog_to_rebuild_into() {
|
|
// The second NFR-R6 branch. What makes it a rebuild rather than a data
|
|
// loss is invariant §5.2.4, which lives outside this crate — what is
|
|
// testable here is that the damaged file is out of the way, kept, and
|
|
// that the next open succeeds on an empty catalog at the current
|
|
// schema, which is what a scan then fills.
|
|
let dir = tempdir("rebuild");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 500);
|
|
corrupt(&path);
|
|
|
|
let kept = set_aside(&path).unwrap().expect("the catalog was there");
|
|
assert!(kept.exists(), "the damaged catalog was deleted, not kept");
|
|
assert!(!path.exists());
|
|
|
|
let cat = Catalog::open_verified(&path).unwrap();
|
|
let images: i64 = cat
|
|
.connection()
|
|
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(images, 0);
|
|
let v: i64 = cat
|
|
.connection()
|
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(v, schema::SCHEMA_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_journal_does_not_follow_the_catalog_into_recovery() {
|
|
// The step that is easy to omit: a `-wal` belonging to the damaged
|
|
// file is replayed into whatever takes its name next.
|
|
let dir = tempdir("journal");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 500);
|
|
std::fs::write(with_suffix(&path, "wal"), b"stale").unwrap();
|
|
|
|
set_aside(&path).unwrap();
|
|
assert!(!with_suffix(&path, "wal").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn a_migration_is_backed_up_before_it_runs() {
|
|
// NFR-R2's second clause, against a real v1 catalog rather than a
|
|
// faked version number: the point is not that *a* file appears but
|
|
// that it holds the state from before the migration, which is the only
|
|
// state that is any use if the migration is what breaks it.
|
|
let dir = tempdir("premigrate");
|
|
let path = dir.join("catalog.sqlite");
|
|
{
|
|
let c = Connection::open(&path).unwrap();
|
|
schema::configure(&c).unwrap();
|
|
// `v1_for_attached` names the schema it targets, and "main" is a
|
|
// schema like any other — so this is the real v1, without needing
|
|
// `V1` itself to become visible outside its module.
|
|
c.execute_batch(&schema::v1_for_attached("main")).unwrap();
|
|
c.pragma_update(None, "user_version", 1).unwrap();
|
|
c.execute(
|
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
crate::sync::checkpoint(&c).unwrap();
|
|
}
|
|
assert!(backups(&path).is_empty());
|
|
|
|
Catalog::open(&path).unwrap();
|
|
|
|
let taken = backups(&path);
|
|
assert_eq!(taken.len(), 1, "no backup was taken before the migration");
|
|
check_file(&taken[0].path).unwrap();
|
|
let kept = Connection::open(&taken[0].path).unwrap();
|
|
let v: i64 = kept
|
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(v, 1, "the backup was taken after the migration, not before");
|
|
}
|
|
|
|
#[test]
|
|
fn opening_an_up_to_date_catalog_takes_no_backup() {
|
|
// Or every background task that opens the catalog would copy it.
|
|
let dir = tempdir("nobackup");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 10);
|
|
Catalog::open(&path).unwrap();
|
|
assert!(backups(&path).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_newest_generations_are_kept() {
|
|
let dir = tempdir("prune");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 10);
|
|
let cat = Catalog::open(&path).unwrap();
|
|
|
|
// Written by hand rather than by calling `backup` in a loop: the
|
|
// filename carries whole seconds, so real calls would collide.
|
|
std::fs::create_dir_all(backup_dir(&path)).unwrap();
|
|
for t in 1..=KEEP_BACKUPS as i64 + 2 {
|
|
drop(
|
|
crate::sync::copy_to(
|
|
cat.connection(),
|
|
&backup_dir(&path).join(format!("catalog-{t}.sqlite")),
|
|
)
|
|
.unwrap(),
|
|
);
|
|
}
|
|
prune(&path);
|
|
|
|
let kept = backups(&path);
|
|
assert_eq!(kept.len(), KEEP_BACKUPS);
|
|
// Newest first, and the newest is the highest timestamp.
|
|
assert_eq!(kept[0].taken_at, KEEP_BACKUPS as i64 + 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stray_file_in_the_backup_directory_is_not_offered_as_one() {
|
|
let dir = tempdir("stray");
|
|
let path = dir.join("catalog.sqlite");
|
|
fixture(&path, 10);
|
|
std::fs::create_dir_all(backup_dir(&path)).unwrap();
|
|
std::fs::write(backup_dir(&path).join("notes.txt"), b"hello").unwrap();
|
|
std::fs::write(backup_dir(&path).join("catalog-7.sqlite-wal"), b"x").unwrap();
|
|
|
|
assert!(backups(&path).is_empty());
|
|
}
|
|
}
|