mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-21 22:11:26 +08:00
fix(official-sync): 清理未被引用的孤儿 staging 目录
同版本失败 staging 复用已由 060b7d7 处理,但跨版本失败、或被 trim/去重挤出
failed_versions 记录后残留的 .staging/<id> 目录永不被复用也无人清理,长期 daemon
场景下多 GB 孤儿目录会累积耗尽磁盘。
新增 gc_orphan_staging:枚举 .staging 下子目录,只保留 in_progress_version 与
failed_versions 引用的 <id>,删除其余(跳过普通文件与 symlink,staging 根不存在
时无操作)。在同步成功发布后(读取最新版本状态)和 clean-stable(后台已停止)两处
调用;GC 失败仅告警,不影响发布结果。
对应 issue #18 维护清单 1-4。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||
use bat_infrastructure::{
|
||||
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state, redact_proxy_url,
|
||||
resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, write_file_atomic,
|
||||
CurlProxyConfig, CurlProxyMode, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
|
||||
PRIVATE_FILE_MODE,
|
||||
gc_orphan_staging, lexical_absolute, open_append_file, read_file_no_symlink,
|
||||
read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root,
|
||||
validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode,
|
||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
|
||||
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
@@ -2979,6 +2979,11 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
removed_paths.push(proxy_secret_path);
|
||||
}
|
||||
|
||||
// 后台已停止,清理未被版本状态引用的孤儿 staging 目录。
|
||||
if let Some(state) = read_version_state(&options.config.version_state_path())? {
|
||||
removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?);
|
||||
}
|
||||
|
||||
let report = CleanStableReport {
|
||||
command: "clean-stable",
|
||||
status: if skipped_paths.is_empty() {
|
||||
|
||||
@@ -53,9 +53,9 @@ pub use official_sync::{
|
||||
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
|
||||
};
|
||||
pub use official_update::{
|
||||
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
|
||||
read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state,
|
||||
ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
||||
read_bootstrap_cache, read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot,
|
||||
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
|
||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
||||
|
||||
@@ -1439,6 +1439,26 @@ impl OfficialUpdateService {
|
||||
));
|
||||
}
|
||||
|
||||
// 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。
|
||||
match read_version_state(&version_state_path) {
|
||||
Ok(Some(state)) => match gc_orphan_staging(&config.output_root, &state) {
|
||||
Ok(removed) if !removed.is_empty() => progress(OfficialUpdateProgress::new(
|
||||
"publish",
|
||||
format!("已清理 {} 个孤儿 staging 目录", removed.len()),
|
||||
)),
|
||||
Ok(_) => {}
|
||||
Err(error) => progress(OfficialUpdateProgress::new(
|
||||
"publish",
|
||||
format!("清理孤儿 staging 目录失败(忽略):{error}"),
|
||||
)),
|
||||
},
|
||||
Ok(None) => {}
|
||||
Err(error) => progress(OfficialUpdateProgress::new(
|
||||
"publish",
|
||||
format!("读取版本状态用于 staging 清理失败(忽略):{error}"),
|
||||
)),
|
||||
}
|
||||
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"finish",
|
||||
format!(
|
||||
@@ -2334,6 +2354,63 @@ fn failed_version_matches(left: &OfficialVersionRecord, right: &OfficialVersionR
|
||||
&& left.addressables_root == right.addressables_root
|
||||
}
|
||||
|
||||
/// 清理 `<output_root>/.staging` 下未被版本状态引用的孤儿目录。
|
||||
///
|
||||
/// 只保留 `in_progress_version` 与 `failed_versions` 引用的 `<id>` 目录(后者供失败
|
||||
/// 后复用),删除其余目录——即跨版本失败、或被 trim/去重挤出记录后残留的 staging,
|
||||
/// 避免长期 daemon 场景下多 GB 孤儿目录累积。返回被删除的目录路径。
|
||||
pub fn gc_orphan_staging(
|
||||
output_root: &Path,
|
||||
state: &OfficialVersionState,
|
||||
) -> anyhow::Result<Vec<PathBuf>> {
|
||||
gc_orphan_staging_dirs(&output_root.join(OFFICIAL_STAGING_DIR), state)
|
||||
}
|
||||
|
||||
fn gc_orphan_staging_dirs(
|
||||
staging_dir: &Path,
|
||||
state: &OfficialVersionState,
|
||||
) -> anyhow::Result<Vec<PathBuf>> {
|
||||
let read_dir = match fs::read_dir(staging_dir) {
|
||||
Ok(read_dir) => read_dir,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"读取官方资源 staging 根目录失败 {}:{error}",
|
||||
staging_dir.display()
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut referenced: Vec<&str> = Vec::new();
|
||||
if let Some(in_progress) = state.in_progress_version.as_ref() {
|
||||
referenced.push(in_progress.id.as_str());
|
||||
}
|
||||
for failed in &state.failed_versions {
|
||||
referenced.push(failed.version.id.as_str());
|
||||
}
|
||||
|
||||
let mut removed = Vec::new();
|
||||
for entry in read_dir {
|
||||
let entry = entry?;
|
||||
// file_type 不跟随 symlink:symlink(即使指向目录)is_dir() 为 false,会被跳过。
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if referenced.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
fs::remove_dir_all(&path).map_err(|error| {
|
||||
anyhow::anyhow!("清理孤儿 staging 目录失败 {}:{error}", path.display())
|
||||
})?;
|
||||
removed.push(path);
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn snapshot_path_for_state_path(
|
||||
_snapshot: &OfficialUpdateSnapshot,
|
||||
resource_root: &Path,
|
||||
@@ -2985,6 +3062,66 @@ mod tests {
|
||||
assert!(state.failed_versions[0].error.contains("403"));
|
||||
}
|
||||
|
||||
fn version_record_with_id(id: &str) -> OfficialVersionRecord {
|
||||
OfficialVersionRecord {
|
||||
id: id.to_string(),
|
||||
app_version: "1.0.0".to_string(),
|
||||
bundle_version: None,
|
||||
addressables_root: "root".to_string(),
|
||||
resource_root: PathBuf::from("/tmp/x"),
|
||||
snapshot_path: PathBuf::from("/tmp/x/snap.json"),
|
||||
staging_path: None,
|
||||
version_path: None,
|
||||
started_unix_seconds: None,
|
||||
completed_unix_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gc_orphan_staging_removes_only_unreferenced_dirs() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path();
|
||||
let staging = root.join(OFFICIAL_STAGING_DIR);
|
||||
for id in ["keep-inprogress", "keep-failed", "orphan-a", "orphan-b"] {
|
||||
fs::create_dir_all(staging.join(id)).unwrap();
|
||||
fs::write(staging.join(id).join("marker"), b"x").unwrap();
|
||||
}
|
||||
// staging 根下的普通文件不应被误删。
|
||||
fs::write(staging.join("stray-file"), b"x").unwrap();
|
||||
|
||||
let state = OfficialVersionState {
|
||||
in_progress_version: Some(version_record_with_id("keep-inprogress")),
|
||||
failed_versions: vec![OfficialFailedVersionRecord {
|
||||
version: version_record_with_id("keep-failed"),
|
||||
error: "boom".to_string(),
|
||||
failed_unix_seconds: 1,
|
||||
}],
|
||||
..OfficialVersionState::default()
|
||||
};
|
||||
|
||||
let removed = gc_orphan_staging(root, &state).unwrap();
|
||||
let mut removed_names: Vec<String> = removed
|
||||
.iter()
|
||||
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
removed_names.sort();
|
||||
assert_eq!(removed_names, vec!["orphan-a", "orphan-b"]);
|
||||
|
||||
assert!(staging.join("keep-inprogress").exists());
|
||||
assert!(staging.join("keep-failed").exists());
|
||||
assert!(!staging.join("orphan-a").exists());
|
||||
assert!(!staging.join("orphan-b").exists());
|
||||
assert!(staging.join("stray-file").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gc_orphan_staging_missing_dir_is_noop() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let removed = gc_orphan_staging(temp.path(), &OfficialVersionState::default()).unwrap();
|
||||
assert!(removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_state_deduplicates_repeated_failures_and_clears_after_success() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user