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:
2026-07-16 07:29:37 -07:00
co-authored by Claude Fable 5
parent 4d818f512c
commit 3cb3637749
3 changed files with 151 additions and 9 deletions
+11 -6
View File
@@ -1,11 +1,11 @@
use bat_adapters::official::yostar_jp::PatchPlatform; use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{ use bat_infrastructure::{
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state, redact_proxy_url, gc_orphan_staging, lexical_absolute, open_append_file, read_file_no_symlink,
resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, write_file_atomic, read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root,
CurlProxyConfig, CurlProxyMode, OfficialFailedVersionRecord, OfficialServerInfoSource, validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
PRIVATE_FILE_MODE, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::env; use std::env;
@@ -2979,6 +2979,11 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
removed_paths.push(proxy_secret_path); 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 { let report = CleanStableReport {
command: "clean-stable", command: "clean-stable",
status: if skipped_paths.is_empty() { status: if skipped_paths.is_empty() {
+3 -3
View File
@@ -53,9 +53,9 @@ pub use official_sync::{
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan, default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
}; };
pub use official_update::{ pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache, cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state, read_bootstrap_cache, read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot,
ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot, write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
+137
View File
@@ -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( progress(OfficialUpdateProgress::new(
"finish", "finish",
format!( format!(
@@ -2334,6 +2354,63 @@ fn failed_version_matches(left: &OfficialVersionRecord, right: &OfficialVersionR
&& left.addressables_root == right.addressables_root && 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 不跟随 symlinksymlink(即使指向目录)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( fn snapshot_path_for_state_path(
_snapshot: &OfficialUpdateSnapshot, _snapshot: &OfficialUpdateSnapshot,
resource_root: &Path, resource_root: &Path,
@@ -2985,6 +3062,66 @@ mod tests {
assert!(state.failed_versions[0].error.contains("403")); 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] #[test]
fn version_state_deduplicates_repeated_failures_and_clears_after_success() { fn version_state_deduplicates_repeated_failures_and_clears_after_success() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();