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 22:22:11 +08:00
parent f2c20367a6
commit 786b739f99
17 changed files with 835 additions and 124 deletions
+185 -8
View File
@@ -100,6 +100,8 @@ struct LocalizedReleaseTransaction {
current_target: Option<PathBuf>,
previous_state_bytes: Option<Vec<u8>>,
new_state: Option<LocalizedVersionState>,
#[serde(default)]
rollback_backup_path: Option<PathBuf>,
}
impl LocalizedReleaseTransaction {
@@ -121,6 +123,7 @@ impl LocalizedReleaseTransaction {
current_target: Some(Path::new(LOCALIZED_VERSIONS_DIR).join(release_id)),
previous_state_bytes,
new_state: None,
rollback_backup_path: None,
}
}
}
@@ -941,9 +944,33 @@ impl LocalizedPatchService {
current_target: manifest.rollback.previous_current_target.clone(),
previous_state_bytes: Some(previous_state_bytes),
new_state: Some(new_state.clone()),
rollback_backup_path: Some(localized_output_root.join(".rollback").join(format!(
"{}.{}",
current_release_id,
std::process::id()
))),
};
write_localized_transaction(localized_output_root, &transaction)?;
let mutation_result = (|| -> anyhow::Result<()> {
let backup_path = transaction
.rollback_backup_path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("localized rollback 缺少备份路径"))?;
ensure_path_within_root(localized_output_root, backup_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(
backup_path.parent().unwrap_or(localized_output_root),
"localized rollback 备份目录",
)
.map_err(anyhow::Error::msg)?;
fs::create_dir_all(backup_path.parent().unwrap_or(localized_output_root))?;
ensure_safe_directory_path(
backup_path.parent().unwrap_or(localized_output_root),
"localized rollback 备份目录",
)
.map_err(anyhow::Error::msg)?;
fs::rename(&remove_version_path, backup_path)?;
update_localized_transaction_phase(localized_output_root, "version_staged")?;
restore_current_symlink(
localized_output_root,
&current_path,
@@ -952,8 +979,18 @@ impl LocalizedPatchService {
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)?;
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
if !current_points_to_version(
&current_path,
&localized_output_root.join(previous_target),
)? {
return Err(anyhow::anyhow!("localized rollback current 最终校验失败"));
}
} else if fs::symlink_metadata(&current_path).is_ok() {
return Err(anyhow::anyhow!("localized rollback 应移除 current 指针"));
}
update_localized_transaction_phase(localized_output_root, "version_removed")?;
remove_owned_path(backup_path)?;
Ok(())
})();
if let Err(error) = mutation_result {
@@ -1216,6 +1253,7 @@ impl LocalizedPatchService {
translation_workflow_status,
updated_unix_seconds: unix_seconds_now(),
};
update_localized_transaction_state(&config.localized_output_root, &state)?;
write_file_atomic(
&state_path,
&serde_json::to_vec_pretty(&state)?,
@@ -1230,6 +1268,7 @@ impl LocalizedPatchService {
&current_path,
&config.unzip_command,
)?;
update_localized_transaction_phase(&config.localized_output_root, "verified")?;
Ok(LocalizedPatchReport {
version_path,
@@ -2139,6 +2178,7 @@ fn verify_published_localized_release(
&manifest,
unzip_command,
)?;
verify_localized_distribution_manifest_at(version_path, &manifest.localized_release_id)?;
integrity.current_points_to_release = current_points_to_version(current_path, version_path)?;
if !integrity.current_points_to_release {
return Err(anyhow::anyhow!(
@@ -2147,6 +2187,23 @@ fn verify_published_localized_release(
version_path.display()
));
}
let state_path = current_path
.parent()
.ok_or_else(|| anyhow::anyhow!("localized current 缺少输出根目录"))?
.join(LOCALIZED_VERSION_STATE_FILE);
let state = read_localized_version_state(
current_path
.parent()
.ok_or_else(|| anyhow::anyhow!("localized current 缺少输出根目录"))?,
)?
.ok_or_else(|| anyhow::anyhow!("缺少 localized version state{}", state_path.display()))?;
if state.current_release_id.as_deref() != Some(manifest.localized_release_id.as_str()) {
return Err(anyhow::anyhow!(
"localized version state 与发布 manifest 不一致:expected={} actual={:?}",
manifest.localized_release_id,
state.current_release_id
));
}
Ok(integrity)
}
@@ -2289,6 +2346,24 @@ fn update_localized_transaction_phase(
write_localized_transaction(localized_output_root, &transaction)
}
fn update_localized_transaction_state(
localized_output_root: &Path,
state: &LocalizedVersionState,
) -> 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.new_state = Some(state.clone());
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) {
@@ -2341,15 +2416,14 @@ fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result
== Some(expected)
});
let publish_committed = transaction.operation == "publish"
&& transaction.phase == "verified"
&& 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;
&& state_matches;
let rollback_committed = transaction.operation == "rollback"
&& transaction.phase == "version_removed"
&& current_matches
&& state_matches;
if publish_committed {
if let Some(staging) = transaction.staging_path.as_deref() {
@@ -2357,6 +2431,9 @@ fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result
}
} else if rollback_committed {
remove_owned_path(&transaction.version_path)?;
if let Some(backup) = transaction.rollback_backup_path.as_deref() {
remove_owned_path(backup)?;
}
} else {
if let Some(target) = transaction.previous_current_target.as_deref() {
let target_path = localized_output_root.join(target);
@@ -2372,6 +2449,14 @@ fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result
}
remove_owned_path(&transaction.version_path)?;
}
if transaction.operation == "rollback" {
if let Some(backup) = transaction.rollback_backup_path.as_deref() {
if fs::symlink_metadata(backup).is_ok() {
remove_owned_path(&transaction.version_path)?;
fs::rename(backup, &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),
@@ -3533,6 +3618,98 @@ mod tests {
assert!(!localized_output_transaction_pending(&root).unwrap());
}
#[cfg(unix)]
#[test]
fn publish_recovery_requires_verified_phase_before_roll_forward() {
use std::os::unix::fs::symlink;
for (phase, new_is_current, should_keep_new) in [
("prepared", false, false),
("version_published", false, false),
("current_switched", true, false),
("state_written", true, false),
("verification_failed", true, false),
("verification_succeeded", true, false),
("verified", true, true),
] {
let temp = TempDir::new().unwrap();
let root = temp.path().join("localized");
let old = root.join(LOCALIZED_VERSIONS_DIR).join("old");
let new = root.join(LOCALIZED_VERSIONS_DIR).join("new");
let staging = root.join(LOCALIZED_STAGING_DIR).join("new");
fs::create_dir_all(&old).unwrap();
fs::create_dir_all(&new).unwrap();
fs::create_dir_all(&staging).unwrap();
symlink(
Path::new(LOCALIZED_VERSIONS_DIR).join(if new_is_current { "new" } else { "old" }),
root.join(LOCALIZED_CURRENT_LINK),
)
.unwrap();
let old_state = LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: "official-old".to_string(),
current_release_id: Some("old".to_string()),
status: "localized".to_string(),
translation_workflow_status: None,
updated_unix_seconds: 1,
};
let new_state = LocalizedVersionState {
current_release_id: Some("new".to_string()),
updated_unix_seconds: 2,
..old_state.clone()
};
let old_state_bytes = serde_json::to_vec_pretty(&old_state).unwrap();
let state_bytes = if new_is_current {
serde_json::to_vec_pretty(&new_state).unwrap()
} else {
old_state_bytes.clone()
};
write_file_atomic(
&root.join(LOCALIZED_VERSION_STATE_FILE),
&state_bytes,
STATE_FILE_MODE,
"test state",
)
.unwrap();
let mut transaction = LocalizedReleaseTransaction::publish(
"new",
new.clone(),
staging.clone(),
Some(PathBuf::from("versions/old")),
Some(old_state_bytes),
);
transaction.phase = phase.to_string();
transaction.new_state = Some(new_state.clone());
write_localized_transaction(&root, &transaction).unwrap();
recover_localized_transaction(&root).unwrap();
if should_keep_new {
assert_eq!(
fs::read_link(root.join(LOCALIZED_CURRENT_LINK)).unwrap(),
PathBuf::from("versions/new")
);
assert_eq!(
read_localized_version_state(&root).unwrap(),
Some(new_state)
);
assert!(new.exists());
} else {
assert_eq!(
fs::read_link(root.join(LOCALIZED_CURRENT_LINK)).unwrap(),
PathBuf::from("versions/old")
);
assert_eq!(
read_localized_version_state(&root).unwrap(),
Some(old_state)
);
assert!(!new.exists());
}
assert!(!staging.exists());
assert!(!localized_output_transaction_pending(&root).unwrap());
}
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}