fix(peer): preflight legacy migration work
Bound legacy traversal by entry count and cooperative elapsed time, and complete the read-only plan before mutating any source or destination. Reuse the catalog-sized legacy-index read ceiling. Test Plan: - just test - just clippy - focused preflight and oversized migration tests - git diff --check
This commit is contained in:
@@ -4,9 +4,11 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::atomic::{AtomicUsize, Ordering},
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
thread,
|
thread,
|
||||||
time::Instant,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use lanspread_db::content_manifest::{MAX_CATALOG_ENTRIES, MAX_CATALOG_MANIFEST_BYTES};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
game_paths::{
|
game_paths::{
|
||||||
LEGACY_FIRST_START_DONE_FILE,
|
LEGACY_FIRST_START_DONE_FILE,
|
||||||
@@ -22,7 +24,69 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MIGRATION_CONCURRENCY: usize = 16;
|
const MIGRATION_CONCURRENCY: usize = 16;
|
||||||
const MAX_LEGACY_LIBRARY_INDEX_BYTES: u64 = 128 * 1024 * 1024;
|
const MAX_LEGACY_LIBRARY_INDEX_BYTES: u64 = MAX_CATALOG_MANIFEST_BYTES;
|
||||||
|
const MAX_MIGRATION_SCAN_ENTRIES: usize = MAX_CATALOG_ENTRIES * 10 + 1;
|
||||||
|
const MAX_MIGRATION_SCAN_DURATION: Duration = Duration::from_mins(10);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct MigrationScanLimits {
|
||||||
|
entry_cap: usize,
|
||||||
|
time_budget: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MigrationScanLimits {
|
||||||
|
const PRODUCTION: Self = Self {
|
||||||
|
entry_cap: MAX_MIGRATION_SCAN_ENTRIES,
|
||||||
|
time_budget: MAX_MIGRATION_SCAN_DURATION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MigrationGameRoot {
|
||||||
|
id: String,
|
||||||
|
path: PathBuf,
|
||||||
|
unknown_softlan_files: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MigrationScanBudget {
|
||||||
|
limits: MigrationScanLimits,
|
||||||
|
entries: usize,
|
||||||
|
started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MigrationScanBudget {
|
||||||
|
fn new(limits: MigrationScanLimits) -> Self {
|
||||||
|
Self {
|
||||||
|
limits,
|
||||||
|
entries: 0,
|
||||||
|
started: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn charge(&mut self) -> std::io::Result<()> {
|
||||||
|
self.entries = self.entries.checked_add(1).ok_or_else(|| {
|
||||||
|
std::io::Error::new(ErrorKind::InvalidData, "migration entry count overflow")
|
||||||
|
})?;
|
||||||
|
if self.entries > self.limits.entry_cap {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
format!(
|
||||||
|
"legacy migration exceeds the {}-entry scan budget",
|
||||||
|
self.limits.entry_cap
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.started.elapsed() >= self.limits.time_budget {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::TimedOut,
|
||||||
|
format!(
|
||||||
|
"legacy migration exceeds the {:?} scan-time budget",
|
||||||
|
self.limits.time_budget
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
||||||
pub struct MigrationReport {
|
pub struct MigrationReport {
|
||||||
@@ -52,12 +116,21 @@ impl MigrationReport {
|
|||||||
/// This is intentionally separate from normal operation: callers should run it
|
/// This is intentionally separate from normal operation: callers should run it
|
||||||
/// before starting the peer runtime for a game directory.
|
/// before starting the peer runtime for a game directory.
|
||||||
pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
||||||
|
// Keep the public asynchronous boundary cooperative before entering the
|
||||||
|
// finite, lexically scoped filesystem batch.
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
migrate_legacy_state_with_limits(game_dir, state_dir, MigrationScanLimits::PRODUCTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_legacy_state_with_limits(
|
||||||
|
game_dir: &Path,
|
||||||
|
state_dir: &Path,
|
||||||
|
limits: MigrationScanLimits,
|
||||||
|
) -> MigrationReport {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let mut report = MigrationReport::default();
|
let mut report = MigrationReport::default();
|
||||||
|
|
||||||
report.merge(migrate_library_index(game_dir, state_dir));
|
let game_roots = match scoped_blocking(|| collect_game_roots(game_dir, limits)) {
|
||||||
|
|
||||||
let game_roots = match scoped_blocking(|| collect_game_roots(game_dir)) {
|
|
||||||
Ok(game_roots) => game_roots,
|
Ok(game_roots) => game_roots,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if err.kind() != ErrorKind::NotFound {
|
if err.kind() != ErrorKind::NotFound {
|
||||||
@@ -72,6 +145,10 @@ pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> Migratio
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Complete the selected-root enumeration before mutating either legacy or
|
||||||
|
// current state. A budget failure therefore leaves the prior migration
|
||||||
|
// generation intact instead of committing a truncated subset.
|
||||||
|
report.merge(migrate_library_index(game_dir, state_dir));
|
||||||
report.merge(scoped_blocking(|| {
|
report.merge(scoped_blocking(|| {
|
||||||
migrate_game_roots(state_dir, &game_roots)
|
migrate_game_roots(state_dir, &game_roots)
|
||||||
}));
|
}));
|
||||||
@@ -80,10 +157,15 @@ pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> Migratio
|
|||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
|
fn collect_game_roots(
|
||||||
|
game_dir: &Path,
|
||||||
|
limits: MigrationScanLimits,
|
||||||
|
) -> std::io::Result<Vec<MigrationGameRoot>> {
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
|
let mut budget = MigrationScanBudget::new(limits);
|
||||||
for entry in fs::read_dir(game_dir)? {
|
for entry in fs::read_dir(game_dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
|
budget.charge()?;
|
||||||
if !entry.file_type()?.is_dir() {
|
if !entry.file_type()?.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -95,12 +177,25 @@ fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
roots.push((id, entry.path()));
|
let path = entry.path();
|
||||||
|
let unknown_softlan_files = count_unknown_softlan_files(&path, &mut budget)?
|
||||||
|
.checked_add(count_unknown_softlan_files(
|
||||||
|
&path.join("local"),
|
||||||
|
&mut budget,
|
||||||
|
)?)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(ErrorKind::InvalidData, "legacy marker count overflow")
|
||||||
|
})?;
|
||||||
|
roots.push(MigrationGameRoot {
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
unknown_softlan_files,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(roots)
|
Ok(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn migrate_game_roots(state_dir: &Path, game_roots: &[(String, PathBuf)]) -> MigrationReport {
|
fn migrate_game_roots(state_dir: &Path, game_roots: &[MigrationGameRoot]) -> MigrationReport {
|
||||||
if game_roots.is_empty() {
|
if game_roots.is_empty() {
|
||||||
return MigrationReport::default();
|
return MigrationReport::default();
|
||||||
}
|
}
|
||||||
@@ -116,10 +211,10 @@ fn migrate_game_roots(state_dir: &Path, game_roots: &[(String, PathBuf)]) -> Mig
|
|||||||
let mut report = MigrationReport::default();
|
let mut report = MigrationReport::default();
|
||||||
loop {
|
loop {
|
||||||
let index = next_root.fetch_add(1, Ordering::Relaxed);
|
let index = next_root.fetch_add(1, Ordering::Relaxed);
|
||||||
let Some((id, root)) = game_roots.get(index) else {
|
let Some(root) = game_roots.get(index) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
report.merge(migrate_game_root(state_dir, id, root));
|
report.merge(migrate_game_root(state_dir, root));
|
||||||
}
|
}
|
||||||
report
|
report
|
||||||
}));
|
}));
|
||||||
@@ -165,16 +260,21 @@ fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
|||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
fn migrate_game_root(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
|
fn migrate_game_root(state_dir: &Path, game_root: &MigrationGameRoot) -> MigrationReport {
|
||||||
|
let MigrationGameRoot {
|
||||||
|
id,
|
||||||
|
path: root,
|
||||||
|
unknown_softlan_files,
|
||||||
|
} = game_root;
|
||||||
let mut report = MigrationReport {
|
let mut report = MigrationReport {
|
||||||
games_checked: 1,
|
games_checked: 1,
|
||||||
|
unknown_softlan_files: *unknown_softlan_files,
|
||||||
..MigrationReport::default()
|
..MigrationReport::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
report.merge(note_legacy_install_intent(root));
|
report.merge(note_legacy_install_intent(root));
|
||||||
report.merge(migrate_setup_marker(state_dir, id, root));
|
report.merge(migrate_setup_marker(state_dir, id, root));
|
||||||
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)));
|
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)));
|
||||||
report.merge(note_unknown_softlan_files(root));
|
|
||||||
|
|
||||||
report
|
report
|
||||||
}
|
}
|
||||||
@@ -241,31 +341,20 @@ fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationRep
|
|||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
|
fn count_unknown_softlan_files(
|
||||||
MigrationReport {
|
dir: &Path,
|
||||||
unknown_softlan_files: scoped_blocking(|| {
|
budget: &mut MigrationScanBudget,
|
||||||
count_unknown_softlan_files(root) + count_unknown_softlan_files(&root.join("local"))
|
) -> std::io::Result<usize> {
|
||||||
}),
|
|
||||||
..MigrationReport::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_unknown_softlan_files(dir: &Path) -> usize {
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
let entries = match fs::read_dir(dir) {
|
let entries = match fs::read_dir(dir) {
|
||||||
Ok(entries) => entries,
|
Ok(entries) => entries,
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
|
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(0),
|
||||||
Err(err) => {
|
Err(err) => return Err(err),
|
||||||
log::warn!(
|
|
||||||
"Failed to inspect {} for legacy .softlan files: {err}",
|
|
||||||
dir.display()
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let Ok(entry) = entry else { break };
|
let entry = entry?;
|
||||||
|
budget.charge()?;
|
||||||
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -276,13 +365,9 @@ fn count_unknown_softlan_files(dir: &Path) -> usize {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
count += 1;
|
count += 1;
|
||||||
log::info!(
|
|
||||||
"Leaving unknown legacy .softlan file in place: {}",
|
|
||||||
entry.path().display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
count
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -616,6 +701,54 @@ mod tests {
|
|||||||
assert!(!target_path.exists());
|
assert!(!target_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn selected_root_budget_failure_precedes_every_migration_mutation() {
|
||||||
|
let games = TempDir::new("lanspread-migration-budget-games");
|
||||||
|
let state = TempDir::new("lanspread-migration-budget-state");
|
||||||
|
let legacy_index = legacy_library_index_path(games.path());
|
||||||
|
let legacy_marker = games
|
||||||
|
.path()
|
||||||
|
.join("game")
|
||||||
|
.join(LEGACY_SOFTLAN_INSTALL_MARKER);
|
||||||
|
write_file(&legacy_index, br#"{"revision":7,"games":{}}"#);
|
||||||
|
write_file(&legacy_marker, b"");
|
||||||
|
|
||||||
|
let report = migrate_legacy_state_with_limits(
|
||||||
|
games.path(),
|
||||||
|
state.path(),
|
||||||
|
MigrationScanLimits {
|
||||||
|
entry_cap: 0,
|
||||||
|
time_budget: MAX_MIGRATION_SCAN_DURATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert!(!report.library_index_migrated);
|
||||||
|
assert!(legacy_index.exists(), "legacy index must remain in place");
|
||||||
|
assert!(
|
||||||
|
legacy_marker.exists(),
|
||||||
|
"per-game marker must remain in place"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!local_library_index_path(state.path()).exists(),
|
||||||
|
"budget failure must not publish a partial current index"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_scan_time_budget_is_explicit() {
|
||||||
|
let mut budget = MigrationScanBudget::new(MigrationScanLimits {
|
||||||
|
entry_cap: 1,
|
||||||
|
time_budget: Duration::ZERO,
|
||||||
|
});
|
||||||
|
|
||||||
|
let error = budget
|
||||||
|
.charge()
|
||||||
|
.expect_err("zero-duration migration scan must fail immediately");
|
||||||
|
assert_eq!(error.kind(), ErrorKind::TimedOut);
|
||||||
|
assert!(error.to_string().contains("scan-time budget"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn existing_app_index_wins_without_reading_oversized_legacy_bytes() {
|
async fn existing_app_index_wins_without_reading_oversized_legacy_bytes() {
|
||||||
let games = TempDir::new("lanspread-migration-games");
|
let games = TempDir::new("lanspread-migration-games");
|
||||||
|
|||||||
Reference in New Issue
Block a user