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:
@@ -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.",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user