Card data update: release reference.db before replacing it

Windows refuses to replace a file SQLite has open, so every update after the first failed at the final swap with "Access is denied". Building and installing are now separate: the app stays usable during the build, then closes the catalog, swaps the file and reopens under the state lock. The swap retries briefly in case a virus scanner is holding the new file, and a failed one puts the previous data back in service and removes the temp file. Importing a card database shares the same swap.
This commit is contained in:
2026-10-30 20:15:34 -05:00
parent 70c89a5644
commit c527e5d6f7
4 changed files with 144 additions and 36 deletions

View File

@@ -15,9 +15,14 @@ fn main() -> ExitCode {
eprintln!("Building reference db at {}", dest.display());
let cancel = CancelToken::new();
match mtgc_core::ingest::build_reference_db(&dest, &cancel, |p| {
let built = mtgc_core::ingest::build_reference_db(&dest, &cancel, |p| {
eprintln!(" [{}] {:.1}% {}", p.stage, p.overall_percent, p.message)
}) {
})
.and_then(|(tmp, stats)| {
mtgc_core::ingest::install_reference_db(&tmp, &dest)?;
Ok(stats)
});
match built {
Ok(stats) => {
eprintln!(
"Done: {} oracle cards, {} printings.",

View File

@@ -9,9 +9,10 @@
//! memory.
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use flate2::read::GzDecoder;
use rusqlite::{params, Connection};
@@ -139,31 +140,55 @@ fn overall_percent(stages: &[IngestStageInfo], active: &str, local_fraction: f64
(acc * 100.0).min(100.0)
}
/// Download the latest Scryfall bulk data and (re)build `reference.db` at
/// `dest`. Builds into a temp file alongside `dest` and only replaces it on
/// full success, so a cancelled or failed update never destroys a working
/// reference db.
/// Download the latest Scryfall bulk data and build a new `reference.db` in a
/// temp file alongside `dest`, returning its path. `dest` itself is untouched,
/// so a cancelled or failed update never destroys a working reference db, and
/// it can stay in use for the whole build. Finish with [`install_reference_db`].
pub fn build_reference_db(
dest: &Path,
cancel: &CancelToken,
mut progress: impl FnMut(IngestProgress),
) -> Result<IngestStats> {
) -> Result<(PathBuf, IngestStats)> {
let tmp = dest.with_extension("db.tmp");
let result = build_into(&tmp, cancel, &mut progress);
match &result {
Ok(_) => {
// Rename straight over `dest`: POSIX rename replaces the
// destination atomically, so there is never a moment with no
// reference.db. (Deleting `dest` first would open a window where a
// process kill — far likelier on Android, which reaps backgrounded
// apps — leaves the user with no card data at all.)
std::fs::rename(&tmp, dest)?;
}
Err(_) => {
match build_into(&tmp, cancel, &mut progress) {
Ok(stats) => Ok((tmp, stats)),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
/// Times to try replacing the reference db, and the wait between tries.
const INSTALL_ATTEMPTS: u32 = 5;
const INSTALL_RETRY_DELAY: Duration = Duration::from_millis(200);
/// Move a built reference db over `dest`, consuming `built` even on failure.
///
/// Anything with `dest` open must let go first: Windows refuses to replace a
/// file SQLite has open. A virus scanner can also hold a file this size for a
/// moment after it's written, and giving up here would throw away a download
/// of several minutes, hence the retries.
pub fn install_reference_db(built: &Path, dest: &Path) -> std::io::Result<()> {
let mut attempt = 1;
loop {
// Rename straight over `dest`: rename replaces the destination
// atomically, so there is never a moment with no reference.db.
// (Deleting `dest` first would open a window where a process kill — far
// likelier on Android, which reaps backgrounded apps — leaves the user
// with no card data at all.)
match std::fs::rename(built, dest) {
Ok(()) => return Ok(()),
Err(_) if attempt < INSTALL_ATTEMPTS => {
attempt += 1;
std::thread::sleep(INSTALL_RETRY_DELAY);
}
Err(e) => {
let _ = std::fs::remove_file(built);
return Err(e);
}
}
}
result
}
fn build_into(
@@ -1483,4 +1508,33 @@ mod tests {
assert_eq!(fetch("p-null"), 0.0);
assert_eq!(fetch("p-noprices"), 0.0);
}
#[test]
fn installing_replaces_the_reference_db() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let built = dest.with_extension("db.tmp");
std::fs::write(&dest, b"old").unwrap();
std::fs::write(&built, b"new").unwrap();
install_reference_db(&built, &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
assert!(!built.exists());
}
#[test]
fn a_failed_install_cleans_up_and_keeps_what_was_there() {
let dir = tempfile::tempdir().unwrap();
// A folder can't be renamed over, on any platform.
let dest = dir.path().join("reference.db");
std::fs::create_dir(&dest).unwrap();
let built = dest.with_extension("db.tmp");
std::fs::write(&built, b"new").unwrap();
install_reference_db(&built, &dest).unwrap_err();
assert!(dest.is_dir());
assert!(!built.exists());
}
}