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

@@ -127,15 +127,15 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res<Status> {
.map_err(|e| e.to_string())?;
db.lock().unwrap().ingest_cancel = None;
let stats = crate::logged("update_card_data", result.map_err(|e| e.to_string()))?;
let (built, stats) = crate::logged("update_card_data", result.map_err(|e| e.to_string()))?;
crate::mlog!(
"update_card_data: ok ({} oracle cards, {} printings)",
"update_card_data: built ({} oracle cards, {} printings)",
stats.oracle_cards,
stats.printings
);
let mut s = db.lock().unwrap();
crate::logged("update_card_data reopen", s.reopen())?;
crate::logged("update_card_data install", s.replace_reference(&built))?;
Ok(status_of(&s))
}
@@ -182,16 +182,7 @@ pub fn import_reference_db(app: AppHandle, db: Db, path: String) -> Res<Status>
})?;
crate::mlog!("import_reference_db: source holds {cards} oracle cards");
// Release the handles on the file we're about to replace.
s.close();
if let Err(e) = std::fs::rename(&tmp, &dest) {
let _ = std::fs::remove_file(&tmp);
let _ = s.reopen(); // put the previous db back into service
return Err(format!("Couldn't install that card database: {e}"));
}
s.reopen()?;
s.replace_reference(&tmp)?;
Ok(status_of(&s))
}

View File

@@ -434,13 +434,26 @@ impl AppState {
}
/// Drop the open catalog (and its name index), releasing the handles on both
/// db files. Call before replacing `reference.db` on disk so nothing is
/// still reading the file being swapped out.
pub fn close(&mut self) {
/// db files.
fn close(&mut self) {
self.catalog = None;
self.index = None;
}
/// Put a newly built reference db into service, consuming `built`.
///
/// The catalog has `reference.db` open, and Windows won't replace an open
/// file, so it lets go first. Holding `&mut self` keeps every command out
/// until the catalog is back.
pub fn replace_reference(&mut self, built: &Path) -> Result<(), String> {
self.close();
let installed = mtgc_core::ingest::install_reference_db(built, &self.reference_path);
// Either way there's a db to reopen: if the swap failed, the previous one.
let reopened = self.reopen();
installed.map_err(|e| format!("Couldn't install the new card data: {e}"))?;
reopened
}
/// (Re)open the catalog + rebuild the name index. Only opens when a reference
/// db exists — attaching a non-existent file would create an empty (tableless)
/// db and make every query fail.
@@ -513,6 +526,51 @@ mod tests {
}
}
/// A reference db holding one card called `name`, at `path`.
fn reference_with(path: &Path, name: &str) {
mtgc_core::db::open_reference_for_build(path)
.unwrap()
.execute_batch(&format!(
"INSERT INTO oracle_cards(oracle_id, name, name_lower) VALUES ('{name}', '{name}', '{name}');"
))
.unwrap();
}
#[test]
fn replacing_the_reference_reopens_on_the_new_file() {
let dir = scratch("replace-reference");
reference_with(&dir.join("reference.db"), "Old");
let mut s = AppState::new(dir.clone());
assert!(s.catalog.is_some());
let built = dir.join("reference.db.tmp");
reference_with(&built, "New");
s.replace_reference(&built).unwrap();
assert_eq!(s.index().unwrap().len(), 1);
assert_eq!(s.index().unwrap().search("New", 10).len(), 1);
assert!(s.catalog.is_some());
assert!(!built.exists());
drop(s);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_failed_replacement_keeps_the_previous_reference_in_service() {
let dir = scratch("replace-reference-failed");
reference_with(&dir.join("reference.db"), "Old");
let mut s = AppState::new(dir.clone());
let missing = dir.join("reference.db.tmp");
let err = s.replace_reference(&missing).unwrap_err();
assert!(err.contains("Couldn't install the new card data"), "got: {err}");
assert_eq!(s.index().unwrap().search("Old", 10).len(), 1);
assert!(s.catalog.is_some());
drop(s);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn every_setting_round_trips() {
let dir = scratch("round-trip");