生成任务先登记 live 再落账本,关掉被判成中断残留的窗口
- `start_local_project_asset_generation` 的音频分支改为一律先登记 live、再写排队记录(原先顺序 相反,中间窗口里并发 `list_local_project_asset_generations` 会把这条排队记录当成上次运行的 残留收口成失败) - 图片类分支同一处顺序问题一并改正:同一条命令的两条通道共用同一条顺序约束 - 新增 `register_live_task_id`:登记并返回「本轮是否真的插入了新 id」,落账失败时只回滚本轮插入 的那条登记,避免删掉同 id 那个正在运行任务的 live 登记 - 新增用例 `a_failed_ledger_write_only_takes_back_the_live_registration_it_inserted` 覆盖该回滚判据
This commit is contained in:
@@ -342,6 +342,21 @@ fn remove_live_task_id(task_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记一条「本进程正在推的任务」,返回 true 表示这次确实插入了新的 id。
|
||||
///
|
||||
/// 顺序是硬约束:**先登记 live 再落账本**。`list` 只把「非终态且不 live」的记录判为上次运行的
|
||||
/// 残留,反过来先落账本就会留出一个窗口——并发 `list` 会在窗口里把刚排队的任务收口成失败,
|
||||
/// 前端随即看到一条本不存在的失败记录。
|
||||
///
|
||||
/// 返回值专给「落账失败要回滚」用:只有真插入过的一方才有资格回滚,否则会把**同 id 那个正在
|
||||
/// 运行的任务**的 live 登记一起删掉(随后 `list` 就会把它谎报成上次运行的中断残留)。
|
||||
fn register_live_task_id(task_id: &str) -> bool {
|
||||
live_task_ids()
|
||||
.lock()
|
||||
.map(|mut ids| ids.insert(task_id.to_string()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。
|
||||
async fn run_local_project_asset_generation_task(
|
||||
root: PathBuf,
|
||||
@@ -531,7 +546,9 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
if idempotency_key.trim().is_empty() {
|
||||
return Err("音频生成缺少 idempotencyKey".to_string());
|
||||
}
|
||||
let (record, request) = begin_local_project_audio_generation_task(
|
||||
// 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。
|
||||
let live_registered = register_live_task_id(&task_id);
|
||||
let (record, request) = match begin_local_project_audio_generation_task(
|
||||
&project_path,
|
||||
&project_id,
|
||||
&task_id,
|
||||
@@ -539,11 +556,16 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
&prompt,
|
||||
asset_name.as_deref().unwrap_or_default(),
|
||||
&idempotency_key,
|
||||
)?;
|
||||
// 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。
|
||||
if let Ok(mut ids) = live_task_ids().lock() {
|
||||
ids.insert(task_id.clone());
|
||||
}
|
||||
) {
|
||||
Ok(pair) => pair,
|
||||
Err(error) => {
|
||||
// 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。
|
||||
if live_registered {
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
tauri::async_runtime::spawn(run_local_project_audio_generation_task(
|
||||
project_path.trim().to_string(),
|
||||
task_id,
|
||||
@@ -566,18 +588,24 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
enforce_project_permission_policy(&request.root, "asset.register")?;
|
||||
let asset_label = request.options.asset_label.clone();
|
||||
let asset_kind = request.options.asset_kind.clone();
|
||||
let record = begin_local_project_asset_generation_task(
|
||||
// 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。
|
||||
let live_registered = register_live_task_id(&task_id);
|
||||
let record = match begin_local_project_asset_generation_task(
|
||||
&request.root,
|
||||
&project_id,
|
||||
&task_id,
|
||||
asset_kind,
|
||||
&asset_label,
|
||||
request.options.output_path.as_deref(),
|
||||
)?;
|
||||
// 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。
|
||||
if let Ok(mut ids) = live_task_ids().lock() {
|
||||
ids.insert(task_id.clone());
|
||||
}
|
||||
) {
|
||||
Ok(record) => record,
|
||||
Err(error) => {
|
||||
if live_registered {
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let root = request.root.clone();
|
||||
tauri::async_runtime::spawn(run_local_project_asset_generation_task(
|
||||
root,
|
||||
@@ -842,6 +870,49 @@ mod asset_generation_task_tests {
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 落账失败要回滚的是「**本轮**插入的那条登记」,不是「这个 id」。
|
||||
///
|
||||
/// 同 id 已经在跑时,`start` 的第二轮不会插入新登记;这时如果按 id 回滚,就会把正在跑的
|
||||
/// 那条任务的 live 登记一起删掉,`list` 随后把它谎报成上次运行的中断残留。
|
||||
#[test]
|
||||
fn a_failed_ledger_write_only_takes_back_the_live_registration_it_inserted() {
|
||||
let root = temp_project_root("live-rollback");
|
||||
// 第一次提交:先登记 live,再落账(真实链路里紧接着 spawn)。
|
||||
assert!(
|
||||
register_live_task_id("task-in-flight"),
|
||||
"首次登记必须报告为「本轮插入」"
|
||||
);
|
||||
begin(&root, "task-in-flight");
|
||||
|
||||
// 第二次提交(同 id):这一轮没有插入新登记,落账也会因「已在进行中」被拒。
|
||||
let live_registered = register_live_task_id("task-in-flight");
|
||||
assert!(
|
||||
!live_registered,
|
||||
"同 id 已在 live 集合里时,本轮不得报告为「本轮插入」"
|
||||
);
|
||||
let error = begin_local_project_asset_generation_task(
|
||||
&root,
|
||||
"project-1",
|
||||
"task-in-flight",
|
||||
GameCreationAppAssetKind::Image,
|
||||
"AI 图",
|
||||
None,
|
||||
)
|
||||
.expect_err("duplicate in-flight task");
|
||||
assert_eq!(error, "生成任务 id 已在进行中:task-in-flight");
|
||||
if live_registered {
|
||||
remove_live_task_id("task-in-flight");
|
||||
}
|
||||
|
||||
// 正在跑的任务仍是 live:`list` 不得把它收口成失败。
|
||||
let listed = list_local_project_asset_generation_tasks(&root).expect("list");
|
||||
assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED);
|
||||
assert_eq!(listed[0].phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED);
|
||||
|
||||
remove_live_task_id("task-in-flight");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_a_task_records_the_manifest_asset_id_and_keeps_it() {
|
||||
let root = temp_project_root("completed");
|
||||
|
||||
Reference in New Issue
Block a user