fix(release): 修复发布一致性与并发边界
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-12 16:35:55 +08:00
parent 8d57a63697
commit 30d1cd77e8
20 changed files with 1100 additions and 102 deletions
+458 -21
View File
@@ -7,7 +7,9 @@ use bat_assetbundle::{
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::fs::{self, OpenOptions};
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -28,6 +30,9 @@ pub const LOCALIZED_VERSIONS_DIR: &str = "versions";
pub const LOCALIZED_VERSION_STATE_FILE: &str = "localized-version-state.json";
/// Per-release patch manifest file name.
pub const LOCALIZED_PATCH_MANIFEST_FILE: &str = "localized-patch-manifest.json";
/// Published per-release distribution metadata with localized bytes.
pub const LOCALIZED_DISTRIBUTION_MANIFEST_FILE: &str = "localized-distribution-manifest.json";
const LOCALIZED_TRANSACTION_FILE: &str = ".localized-transaction.json";
/// Current localized patch manifest schema version.
pub const LOCALIZED_PATCH_MANIFEST_VERSION: u32 = 1;
/// Current localized version state schema version.
@@ -37,6 +42,85 @@ pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING: &str = "manual_proof
/// Human label for `LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING`.
pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL: &str = "人工校对中";
#[derive(Debug)]
struct LocalizedOutputLock {
file: std::fs::File,
}
impl Drop for LocalizedOutputLock {
fn drop(&mut self) {
#[cfg(unix)]
unsafe {
libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
}
}
}
impl LocalizedOutputLock {
fn acquire(root: &Path) -> anyhow::Result<Self> {
ensure_safe_directory_path(root, "汉化输出目录").map_err(anyhow::Error::msg)?;
fs::create_dir_all(root)?;
ensure_safe_directory_path(root, "汉化输出目录").map_err(anyhow::Error::msg)?;
let path = root.join(".localized-release.lock");
ensure_safe_file_target(root, &path, "汉化 release 锁").map_err(anyhow::Error::msg)?;
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)?;
#[cfg(unix)]
{
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
if result != 0 {
return Err(anyhow::anyhow!(
"获取汉化 release 锁失败 {}{}",
path.display(),
std::io::Error::last_os_error()
));
}
}
Ok(Self { file })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LocalizedReleaseTransaction {
version: u32,
operation: String,
phase: String,
release_id: String,
version_path: PathBuf,
staging_path: Option<PathBuf>,
previous_current_target: Option<PathBuf>,
current_target: Option<PathBuf>,
previous_state_bytes: Option<Vec<u8>>,
new_state: Option<LocalizedVersionState>,
}
impl LocalizedReleaseTransaction {
fn publish(
release_id: &str,
version_path: PathBuf,
staging_path: PathBuf,
previous_current_target: Option<PathBuf>,
previous_state_bytes: Option<Vec<u8>>,
) -> Self {
Self {
version: 1,
operation: "publish".to_string(),
phase: "prepared".to_string(),
release_id: release_id.to_string(),
version_path,
staging_path: Some(staging_path),
previous_current_target,
current_target: Some(Path::new(LOCALIZED_VERSIONS_DIR).join(release_id)),
previous_state_bytes,
new_state: None,
}
}
}
/// One patch operation against a bundle in an official release.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedTextAssetPatch {
@@ -287,6 +371,32 @@ pub struct LocalizedVersionState {
pub updated_unix_seconds: u64,
}
/// Actual bytes metadata written alongside a published localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedDistributionEntry {
/// Official URL associated with this release-relative file.
pub url: String,
/// Release-relative destination.
pub destination: String,
/// Actual localized file size.
pub bytes: u64,
/// BLAKE3 of the actual localized file.
pub blake3: String,
}
/// Cheap, trusted distribution index generated at localized publication time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedDistributionManifest {
/// Metadata schema version.
pub version: u32,
/// Official source release identity.
pub official_release_id: String,
/// Localized release identity.
pub localized_release_id: String,
/// Actual metadata for every official manifest entry.
pub entries: Vec<LocalizedDistributionEntry>,
}
impl LocalizedVersionState {
/// Returns the stable translation workflow status, if set.
pub fn translation_workflow_status(&self) -> Option<&str> {
@@ -617,6 +727,9 @@ impl LocalizedPatchService {
/// Copies the official release, applies patches in staging and publishes it.
pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result<LocalizedPatchReport> {
validate_config(config).map_err(anyhow::Error::msg)?;
let _lock = LocalizedOutputLock::acquire(&config.localized_output_root)?;
recover_localized_transaction(&config.localized_output_root)?;
let published_release_id = config.published_release_id().to_string();
let staging = config
.localized_output_root
@@ -627,7 +740,6 @@ impl LocalizedPatchService {
.join(LOCALIZED_VERSIONS_DIR)
.join(&published_release_id);
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
validate_config(config).map_err(anyhow::Error::msg)?;
let state_path = config
.localized_output_root
.join(LOCALIZED_VERSION_STATE_FILE);
@@ -637,9 +749,20 @@ impl LocalizedPatchService {
if let Some(target) = previous_current_target.as_deref() {
validate_previous_current_target(&config.localized_output_root, target)?;
}
let transaction = LocalizedReleaseTransaction::publish(
&published_release_id,
version_path.clone(),
staging.clone(),
previous_current_target.clone(),
previous_state_bytes.clone(),
);
write_localized_transaction(&config.localized_output_root, &transaction)?;
let version_existed_before = version_path.exists();
match self.publish_inner(config, previous_current_target.clone()) {
Ok(report) => Ok(report),
Ok(report) => {
remove_localized_transaction(&config.localized_output_root)?;
Ok(report)
}
Err(error) => {
if let Err(rollback_error) = rollback_failed_publish(
&config.localized_output_root,
@@ -654,6 +777,7 @@ impl LocalizedPatchService {
"{error}; rollback failed: {rollback_error}"
));
}
remove_localized_transaction(&config.localized_output_root)?;
Err(error)
}
}
@@ -666,6 +790,8 @@ impl LocalizedPatchService {
localized_output_root: &Path,
expected_release_id: Option<&str>,
) -> anyhow::Result<LocalizedRollbackReport> {
let _lock = LocalizedOutputLock::acquire(localized_output_root)?;
recover_localized_transaction(localized_output_root)?;
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
.map_err(anyhow::Error::msg)?;
let versions_root = localized_output_root.join(LOCALIZED_VERSIONS_DIR);
@@ -707,6 +833,7 @@ impl LocalizedPatchService {
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
)
})?;
verify_localized_release_files(&version_path, &manifest)?;
if manifest.localized_release_id != current_release_id {
return Err(anyhow::anyhow!(
"manifest release={} 与当前状态 release={} 不一致",
@@ -763,23 +890,19 @@ impl LocalizedPatchService {
)
})?,
);
verify_localized_release_files(&previous_path, restored_manifest.as_ref().unwrap())?;
}
restore_current_symlink(
localized_output_root,
&current_path,
manifest.rollback.previous_current_target.as_ref(),
)?;
remove_owned_path(&remove_version_path)?;
let previous_official_release_id = state.official_release_id;
let previous_workflow_status = state.translation_workflow_status;
let previous_state_bytes = read_file_no_symlink(&state_path, "汉化版本状态")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少汉化版本状态"))?;
let restored_official_release_id = restored_manifest
.as_ref()
.map(|manifest| manifest.official_release_id.clone())
.unwrap_or_else(|| previous_official_release_id.clone());
.unwrap_or_else(|| state.official_release_id.clone());
let previous_workflow_status = state.translation_workflow_status.clone();
let translation_workflow_status =
if restored_official_release_id == previous_official_release_id {
if restored_official_release_id == state.official_release_id {
previous_workflow_status
} else {
None
@@ -796,7 +919,44 @@ impl LocalizedPatchService {
translation_workflow_status,
updated_unix_seconds: unix_seconds_now(),
};
write_localized_version_state(localized_output_root, &new_state)?;
let transaction = LocalizedReleaseTransaction {
version: 1,
operation: "rollback".to_string(),
phase: "prepared".to_string(),
release_id: current_release_id.clone(),
version_path: remove_version_path.clone(),
staging_path: None,
previous_current_target: Some(
Path::new(LOCALIZED_VERSIONS_DIR).join(&current_release_id),
),
current_target: manifest.rollback.previous_current_target.clone(),
previous_state_bytes: Some(previous_state_bytes),
new_state: Some(new_state.clone()),
};
write_localized_transaction(localized_output_root, &transaction)?;
let mutation_result = (|| -> anyhow::Result<()> {
restore_current_symlink(
localized_output_root,
&current_path,
manifest.rollback.previous_current_target.as_deref(),
)?;
update_localized_transaction_phase(localized_output_root, "current_switched")?;
write_localized_version_state_unlocked(localized_output_root, &new_state)?;
update_localized_transaction_phase(localized_output_root, "state_written")?;
remove_owned_path(&remove_version_path)?;
update_localized_transaction_phase(localized_output_root, "version_removed")?;
Ok(())
})();
if let Err(error) = mutation_result {
let recovery = recover_localized_transaction(localized_output_root);
return match recovery {
Ok(()) => Err(error),
Err(recovery_error) => Err(anyhow::anyhow!(
"{error}; localized rollback recovery failed: {recovery_error}"
)),
};
}
remove_localized_transaction(localized_output_root)?;
Ok(LocalizedRollbackReport {
command: "localized.rollback",
@@ -1018,13 +1178,26 @@ impl LocalizedPatchService {
&manifest,
&config.unzip_command,
)?;
if let Some(distribution) =
build_localized_distribution_manifest(&config.official_release_root, &staging, config)?
{
write_file_atomic(
&staging.join(LOCALIZED_DISTRIBUTION_MANIFEST_FILE),
&serde_json::to_vec_pretty(&distribution)?,
STATE_FILE_MODE,
"localized distribution manifest",
)
.map_err(anyhow::Error::msg)?;
}
fs::create_dir_all(config.localized_output_root.join(LOCALIZED_VERSIONS_DIR))?;
fs::rename(&staging, &version_path)?;
update_localized_transaction_phase(&config.localized_output_root, "version_published")?;
switch_current_symlink(
&config.localized_output_root,
&current_path,
config.published_release_id(),
)?;
update_localized_transaction_phase(&config.localized_output_root, "current_switched")?;
let state = LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
@@ -1041,6 +1214,7 @@ impl LocalizedPatchService {
"汉化版本状态",
)
.map_err(anyhow::Error::msg)?;
update_localized_transaction_phase(&config.localized_output_root, "state_written")?;
let integrity = verify_published_localized_release(
&config.official_release_root,
&version_path,
@@ -1589,6 +1763,15 @@ pub fn read_localized_version_state(
pub fn write_localized_version_state(
localized_output_root: &Path,
state: &LocalizedVersionState,
) -> anyhow::Result<PathBuf> {
let _lock = LocalizedOutputLock::acquire(localized_output_root)?;
recover_localized_transaction(localized_output_root)?;
write_localized_version_state_unlocked(localized_output_root, state)
}
fn write_localized_version_state_unlocked(
localized_output_root: &Path,
state: &LocalizedVersionState,
) -> anyhow::Result<PathBuf> {
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
.map_err(anyhow::Error::msg)?;
@@ -1609,6 +1792,8 @@ pub fn mark_localized_manual_proofreading(
localized_output_root: &Path,
official_release_id: &str,
) -> anyhow::Result<LocalizedTranslationWorkflowReport> {
let _lock = LocalizedOutputLock::acquire(localized_output_root)?;
recover_localized_transaction(localized_output_root)?;
let mut state = read_localized_version_state(localized_output_root)?.unwrap_or_else(|| {
LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
@@ -1630,7 +1815,7 @@ pub fn mark_localized_manual_proofreading(
state.translation_workflow_status =
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING.to_string());
state.updated_unix_seconds = unix_seconds_now();
let state_path = write_localized_version_state(localized_output_root, &state)?;
let state_path = write_localized_version_state_unlocked(localized_output_root, &state)?;
Ok(LocalizedTranslationWorkflowReport {
command: "translation-proofread",
@@ -1956,6 +2141,193 @@ fn verify_published_localized_release(
Ok(integrity)
}
fn build_localized_distribution_manifest(
official_release_root: &Path,
staging_root: &Path,
config: &LocalizedPatchConfig,
) -> anyhow::Result<Option<LocalizedDistributionManifest>> {
let Some(official_manifest) =
crate::official_download::read_download_manifest_at(official_release_root)
.map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let mut entries = Vec::with_capacity(official_manifest.entries.len());
for entry in official_manifest.entries.values() {
let path = staging_root.join(&entry.destination);
ensure_path_within_root(staging_root, &path).map_err(anyhow::Error::msg)?;
ensure_safe_file_target(staging_root, &path, "localized distribution 文件")
.map_err(anyhow::Error::msg)?;
let bytes = fs::read(&path)?;
entries.push(LocalizedDistributionEntry {
url: entry.url.clone(),
destination: entry.destination.clone(),
bytes: bytes.len() as u64,
blake3: blake3::hash(&bytes).to_hex().to_string(),
});
}
Ok(Some(LocalizedDistributionManifest {
version: 1,
official_release_id: config.release_id.clone(),
localized_release_id: config.published_release_id().to_string(),
entries,
}))
}
fn verify_localized_release_files(
version_path: &Path,
manifest: &LocalizedPatchManifest,
) -> anyhow::Result<()> {
ensure_safe_directory_path(version_path, "localized release").map_err(anyhow::Error::msg)?;
for file in &manifest.files {
let path = version_path.join(&file.path);
ensure_path_within_root(version_path, &path).map_err(anyhow::Error::msg)?;
ensure_safe_file_target(version_path, &path, "localized release 文件")
.map_err(anyhow::Error::msg)?;
let bytes = fs::read(&path)?;
let actual = blake3::hash(&bytes).to_hex().to_string();
if bytes.len() as u64 != file.localized_bytes || actual != file.localized_blake3 {
return Err(anyhow::anyhow!(
"localized release 文件完整性失败 {}expected bytes={} blake3={} actual bytes={} blake3={}",
file.path,
file.localized_bytes,
file.localized_blake3,
bytes.len(),
actual
));
}
}
Ok(())
}
fn write_localized_transaction(
localized_output_root: &Path,
transaction: &LocalizedReleaseTransaction,
) -> anyhow::Result<()> {
let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE);
write_file_atomic(
&path,
&serde_json::to_vec_pretty(transaction)?,
STATE_FILE_MODE,
"localized release transaction",
)
.map_err(anyhow::Error::msg)
}
fn update_localized_transaction_phase(
localized_output_root: &Path,
phase: &str,
) -> anyhow::Result<()> {
let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE);
let Some(bytes) =
read_file_no_symlink(&path, "localized release transaction").map_err(anyhow::Error::msg)?
else {
return Err(anyhow::anyhow!(
"localized release transaction 丢失:{}",
path.display()
));
};
let mut transaction: LocalizedReleaseTransaction = serde_json::from_slice(&bytes)?;
transaction.phase = phase.to_string();
write_localized_transaction(localized_output_root, &transaction)
}
fn remove_localized_transaction(localized_output_root: &Path) -> anyhow::Result<()> {
let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE);
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(anyhow::anyhow!("localized transaction 不能是 symlink"))
}
Ok(_) => {
fs::remove_file(path)?;
Ok(())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result<()> {
let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE);
let Some(bytes) =
read_file_no_symlink(&path, "localized release transaction").map_err(anyhow::Error::msg)?
else {
return Ok(());
};
let transaction: LocalizedReleaseTransaction = serde_json::from_slice(&bytes)?;
if transaction.version != 1 {
return Err(anyhow::anyhow!(
"不支持的 localized transaction schema{}",
transaction.version
));
}
ensure_path_within_root(localized_output_root, &transaction.version_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&transaction.version_path, "localized transaction release")
.map_err(anyhow::Error::msg)?;
let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK);
let current_matches = match transaction.current_target.as_deref() {
Some(target) => current_path
.read_link()
.map(|current| current == target)
.unwrap_or(false),
None => matches!(
fs::symlink_metadata(&current_path),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
),
};
let state_matches = transaction.new_state.as_ref().is_some_and(|expected| {
read_localized_version_state(localized_output_root)
.ok()
.flatten()
.as_ref()
== Some(expected)
});
let publish_committed = transaction.operation == "publish"
&& transaction.version_path.is_dir()
&& current_matches
&& read_localized_version_state(localized_output_root)
.ok()
.flatten()
.and_then(|state| state.current_release_id)
.is_some_and(|id| id == transaction.release_id);
let rollback_committed =
transaction.operation == "rollback" && current_matches && state_matches;
if publish_committed {
if let Some(staging) = transaction.staging_path.as_deref() {
remove_owned_path(staging)?;
}
} else if rollback_committed {
remove_owned_path(&transaction.version_path)?;
} else {
if let Some(target) = transaction.previous_current_target.as_deref() {
ensure_path_within_root(localized_output_root, target).map_err(anyhow::Error::msg)?;
restore_current_symlink(localized_output_root, &current_path, Some(target))?;
} else if transaction.operation == "publish" {
restore_current_symlink(localized_output_root, &current_path, None)?;
}
if transaction.operation == "publish" {
if let Some(staging) = transaction.staging_path.as_deref() {
remove_owned_path(staging)?;
}
remove_owned_path(&transaction.version_path)?;
}
if let Some(previous_state) = transaction.previous_state_bytes.as_deref() {
write_file_atomic(
&localized_output_root.join(LOCALIZED_VERSION_STATE_FILE),
previous_state,
STATE_FILE_MODE,
"恢复 localized version state",
)
.map_err(anyhow::Error::msg)?;
} else {
remove_owned_path(&localized_output_root.join(LOCALIZED_VERSION_STATE_FILE))?;
}
}
remove_localized_transaction(localized_output_root)
}
/// Inspects one localized release without changing state, staging, current or
/// any repair target.
pub fn inspect_localized_release_artifact(
@@ -2805,7 +3177,11 @@ fn rollback_failed_publish(
.map(|current_target| current_target == *target)
.unwrap_or(false)
}) {
restore_current_symlink(localized_output_root, current_path, previous_current_target)?;
restore_current_symlink(
localized_output_root,
current_path,
previous_current_target.map(PathBuf::as_path),
)?;
}
remove_owned_path(state_path)?;
if let Some(previous_state_bytes) = previous_state_bytes {
@@ -2835,7 +3211,7 @@ fn remove_owned_path(path: &Path) -> anyhow::Result<()> {
fn restore_current_symlink(
root: &Path,
current_path: &Path,
previous_current_target: Option<&PathBuf>,
previous_current_target: Option<&Path>,
) -> anyhow::Result<()> {
use std::os::unix::fs::symlink;
@@ -2857,7 +3233,7 @@ fn restore_current_symlink(
fn restore_current_symlink(
_root: &Path,
_current_path: &Path,
_previous_current_target: Option<&PathBuf>,
_previous_current_target: Option<&Path>,
) -> anyhow::Result<()> {
Ok(())
}
@@ -3280,17 +3656,55 @@ mod tests {
fs::create_dir_all(&target).unwrap();
let binary_source = b"binary-before";
let binary_target = b"binary-after";
let binary_target = b"binary-after-longer";
let json_source = br#"{"value":0}"#;
let json_target = br#"{"value":1}"#;
let text_source = "old text\n";
let text_target = "new text\n";
let text_target = "translated text with a different length\n";
fs::write(official.join("data.bin"), binary_source).unwrap();
fs::write(target.join("data.bin"), binary_target).unwrap();
fs::write(official.join("data.json"), json_source).unwrap();
fs::write(target.join("data.json"), json_target).unwrap();
fs::write(official.join("text.txt"), text_source).unwrap();
fs::write(target.join("text.txt"), text_target).unwrap();
fs::write(
official.join("official-download-manifest.json"),
serde_json::to_vec(&crate::OfficialDownloadManifest {
version: 1,
entries: [
(
"https://example.invalid/data.bin".to_string(),
"data.bin",
binary_source.as_slice(),
),
(
"https://example.invalid/data.json".to_string(),
"data.json",
json_source.as_slice(),
),
(
"https://example.invalid/text.txt".to_string(),
"text.txt",
text_source.as_bytes(),
),
]
.into_iter()
.map(|(url, destination, bytes)| {
(
url.clone(),
crate::OfficialDownloadManifestEntry {
url,
destination: destination.to_string(),
bytes: bytes.len() as u64,
blake3: blake3::hash(bytes).to_hex().to_string(),
},
)
})
.collect(),
})
.unwrap(),
)
.unwrap();
let manifest = bat_patch::build_patch_manifest(
&official,
@@ -3389,6 +3803,29 @@ mod tests {
]
);
assert!(report.integrity.current_points_to_release);
let distribution: LocalizedDistributionManifest = serde_json::from_slice(
&fs::read(
report
.version_path
.join(LOCALIZED_DISTRIBUTION_MANIFEST_FILE),
)
.unwrap(),
)
.unwrap();
assert_eq!(distribution.entries.len(), 3);
for (path, expected) in [
("data.bin", binary_target.as_slice()),
("data.json", json_target.as_slice()),
("text.txt", text_target.as_bytes()),
] {
let entry = distribution
.entries
.iter()
.find(|entry| entry.destination == path)
.unwrap();
assert_eq!(entry.bytes, expected.len() as u64);
assert_eq!(entry.blake3, blake3::hash(expected).to_hex().to_string());
}
}
#[cfg(unix)]