2026-04-07 02:50:19 +08:00
|
|
|
use aether_data_contracts::repository::video_tasks::{
|
|
|
|
|
StoredVideoTask, UpsertVideoTask, VideoTaskStatus,
|
|
|
|
|
};
|
2026-04-03 14:59:58 +08:00
|
|
|
use axum::response::IntoResponse;
|
|
|
|
|
use axum::Json;
|
2026-09-04 03:45:52 +08:00
|
|
|
use serde_json::json;
|
2026-04-03 14:59:58 +08:00
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
use crate::state::VideoTaskRouteAccess;
|
2026-04-05 20:23:16 +08:00
|
|
|
use crate::{AppState, GatewayError};
|
2026-04-03 14:59:58 +08:00
|
|
|
|
|
|
|
|
use super::super::finalize_video_task_if_terminal;
|
2026-09-04 03:45:52 +08:00
|
|
|
use super::super::{read_video_task_detail, read_video_task_detail_for_user};
|
2026-04-03 14:59:58 +08:00
|
|
|
use super::current_unix_secs;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub(crate) enum CancelVideoTaskError {
|
|
|
|
|
NotFound,
|
|
|
|
|
InvalidStatus(VideoTaskStatus),
|
|
|
|
|
Response(axum::response::Response),
|
|
|
|
|
Gateway(GatewayError),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<GatewayError> for CancelVideoTaskError {
|
|
|
|
|
fn from(value: GatewayError) -> Self {
|
|
|
|
|
Self::Gateway(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) async fn cancel_video_task_record(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
task_id: &str,
|
|
|
|
|
) -> Result<StoredVideoTask, CancelVideoTaskError> {
|
2026-09-04 03:45:52 +08:00
|
|
|
cancel_video_task_record_inner(state, task_id, None).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) async fn cancel_video_task_record_for_user(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
task_id: &str,
|
|
|
|
|
user_id: &str,
|
|
|
|
|
) -> Result<StoredVideoTask, CancelVideoTaskError> {
|
|
|
|
|
let user_id = user_id.trim();
|
|
|
|
|
if user_id.is_empty() {
|
|
|
|
|
return Err(CancelVideoTaskError::NotFound);
|
|
|
|
|
}
|
|
|
|
|
cancel_video_task_record_inner(state, task_id, Some(user_id)).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn cancel_video_task_record_inner(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
task_id: &str,
|
|
|
|
|
expected_user_id: Option<&str>,
|
|
|
|
|
) -> Result<StoredVideoTask, CancelVideoTaskError> {
|
|
|
|
|
let task = match expected_user_id {
|
|
|
|
|
Some(user_id) => read_video_task_detail_for_user(state, task_id, user_id).await?,
|
|
|
|
|
None => read_video_task_detail(state, task_id).await?,
|
|
|
|
|
};
|
|
|
|
|
let Some(task) = task else {
|
2026-04-03 14:59:58 +08:00
|
|
|
return Err(CancelVideoTaskError::NotFound);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if matches!(
|
|
|
|
|
task.status,
|
|
|
|
|
VideoTaskStatus::Completed
|
|
|
|
|
| VideoTaskStatus::Failed
|
|
|
|
|
| VideoTaskStatus::Cancelled
|
|
|
|
|
| VideoTaskStatus::Expired
|
|
|
|
|
| VideoTaskStatus::Deleted
|
|
|
|
|
) {
|
|
|
|
|
return Err(CancelVideoTaskError::InvalidStatus(task.status));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let trace_id = format!("async-task-admin-cancel-{task_id}");
|
2026-09-04 03:45:52 +08:00
|
|
|
let mut finalize_mutation = None;
|
2026-04-03 14:59:58 +08:00
|
|
|
if let Some(cancel_plan) = build_video_task_cancel_plan(&task) {
|
|
|
|
|
let body_json = json!({});
|
2026-09-04 03:45:52 +08:00
|
|
|
let follow_up = if let Some(user_id) = expected_user_id {
|
|
|
|
|
if state
|
|
|
|
|
.hydrate_video_task_for_route_for_user(
|
|
|
|
|
Some(cancel_plan.route_family),
|
|
|
|
|
&cancel_plan.request_path,
|
|
|
|
|
user_id,
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
!= VideoTaskRouteAccess::Allowed
|
|
|
|
|
{
|
|
|
|
|
return Err(CancelVideoTaskError::NotFound);
|
|
|
|
|
}
|
|
|
|
|
state.video_tasks.prepare_follow_up_sync_plan_for_user_id(
|
|
|
|
|
cancel_plan.plan_kind,
|
|
|
|
|
&cancel_plan.request_path,
|
|
|
|
|
Some(&body_json),
|
|
|
|
|
user_id,
|
|
|
|
|
task.api_key_id.as_deref(),
|
|
|
|
|
&trace_id,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
state
|
|
|
|
|
.hydrate_video_task_for_route(
|
|
|
|
|
Some(cancel_plan.route_family),
|
|
|
|
|
&cancel_plan.request_path,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
state.video_tasks.prepare_follow_up_sync_plan(
|
|
|
|
|
cancel_plan.plan_kind,
|
|
|
|
|
&cancel_plan.request_path,
|
|
|
|
|
Some(&body_json),
|
|
|
|
|
None,
|
|
|
|
|
&trace_id,
|
|
|
|
|
)
|
|
|
|
|
};
|
2026-04-03 14:59:58 +08:00
|
|
|
|
|
|
|
|
if let Some(follow_up) = follow_up {
|
|
|
|
|
execute_video_task_cancel_plan(state, &trace_id, follow_up.plan)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(CancelVideoTaskError::Response)?;
|
2026-09-04 03:45:52 +08:00
|
|
|
finalize_mutation = Some((
|
|
|
|
|
cancel_plan.request_path,
|
|
|
|
|
cancel_plan.report_kind.to_string(),
|
|
|
|
|
));
|
|
|
|
|
} else if expected_user_id.is_none() {
|
|
|
|
|
finalize_mutation = Some((
|
|
|
|
|
cancel_plan.request_path,
|
|
|
|
|
cancel_plan.report_kind.to_string(),
|
|
|
|
|
));
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
let stored = match persist_cancelled_video_task(state, &task).await? {
|
|
|
|
|
Some(stored) => stored,
|
|
|
|
|
None => {
|
|
|
|
|
let current = match expected_user_id {
|
|
|
|
|
Some(user_id) => read_video_task_detail_for_user(state, task_id, user_id).await?,
|
|
|
|
|
None => read_video_task_detail(state, task_id).await?,
|
|
|
|
|
};
|
|
|
|
|
let Some(current) = current else {
|
|
|
|
|
return Err(CancelVideoTaskError::NotFound);
|
|
|
|
|
};
|
|
|
|
|
if !current.status.is_active() {
|
|
|
|
|
return Err(CancelVideoTaskError::InvalidStatus(current.status));
|
|
|
|
|
}
|
|
|
|
|
return Err(CancelVideoTaskError::Gateway(GatewayError::Internal(
|
2026-04-03 14:59:58 +08:00
|
|
|
"video task repository is unavailable".to_string(),
|
2026-09-04 03:45:52 +08:00
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if let Some((request_path, report_kind)) = finalize_mutation {
|
|
|
|
|
state
|
|
|
|
|
.video_tasks
|
|
|
|
|
.apply_finalize_mutation(&request_path, &report_kind);
|
|
|
|
|
}
|
2026-04-03 14:59:58 +08:00
|
|
|
finalize_video_task_if_terminal(state, &stored).await;
|
|
|
|
|
Ok(stored)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct VideoTaskCancelPlan<'a> {
|
|
|
|
|
route_family: &'a str,
|
|
|
|
|
plan_kind: &'a str,
|
|
|
|
|
report_kind: &'a str,
|
|
|
|
|
request_path: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_video_task_cancel_plan(task: &StoredVideoTask) -> Option<VideoTaskCancelPlan<'_>> {
|
2026-09-04 03:45:52 +08:00
|
|
|
let provider_api_format = task.effective_api_format()?;
|
2026-04-03 14:59:58 +08:00
|
|
|
|
|
|
|
|
match provider_api_format {
|
|
|
|
|
"openai:video" => Some(VideoTaskCancelPlan {
|
|
|
|
|
route_family: "openai",
|
|
|
|
|
plan_kind: "openai_video_cancel_sync",
|
|
|
|
|
report_kind: "openai_video_cancel_sync_finalize",
|
|
|
|
|
request_path: format!("/v1/videos/{}/cancel", task.id),
|
|
|
|
|
}),
|
|
|
|
|
"gemini:video" => {
|
|
|
|
|
let short_id = task.short_id.as_deref().unwrap_or(task.id.as_str()).trim();
|
|
|
|
|
let model = task
|
|
|
|
|
.model
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())?;
|
|
|
|
|
Some(VideoTaskCancelPlan {
|
|
|
|
|
route_family: "gemini",
|
|
|
|
|
plan_kind: "gemini_video_cancel_sync",
|
|
|
|
|
report_kind: "gemini_video_cancel_sync_finalize",
|
|
|
|
|
request_path: format!("/v1beta/models/{model}/operations/{short_id}:cancel"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn execute_video_task_cancel_plan(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
trace_id: &str,
|
|
|
|
|
plan: aether_contracts::ExecutionPlan,
|
|
|
|
|
) -> Result<(), axum::response::Response> {
|
2026-04-07 02:50:19 +08:00
|
|
|
let result =
|
|
|
|
|
crate::execution_runtime::execute_execution_runtime_sync_plan(state, Some(trace_id), &plan)
|
|
|
|
|
.await
|
2026-09-04 03:45:52 +08:00
|
|
|
.map_err(|_| {
|
2026-04-07 02:50:19 +08:00
|
|
|
GatewayError::UpstreamUnavailable {
|
|
|
|
|
trace_id: trace_id.to_string(),
|
2026-09-04 03:45:52 +08:00
|
|
|
message: "video cancellation request failed".to_string(),
|
2026-04-07 02:50:19 +08:00
|
|
|
}
|
|
|
|
|
.into_response()
|
|
|
|
|
})?;
|
2026-04-03 14:59:58 +08:00
|
|
|
|
|
|
|
|
if result.status_code >= 400 {
|
2026-09-04 03:45:52 +08:00
|
|
|
return Err(build_video_task_cancel_upstream_error_response(&result));
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
fn build_video_task_cancel_upstream_error_response(
|
|
|
|
|
result: &aether_contracts::ExecutionResult,
|
|
|
|
|
) -> axum::response::Response {
|
|
|
|
|
let status = axum::http::StatusCode::from_u16(result.status_code)
|
|
|
|
|
.unwrap_or(axum::http::StatusCode::BAD_GATEWAY);
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
event_name = "video_task_cancel_upstream_error",
|
|
|
|
|
upstream_status = result.status_code,
|
|
|
|
|
"video cancellation upstream response body discarded"
|
|
|
|
|
);
|
|
|
|
|
(
|
|
|
|
|
status,
|
|
|
|
|
Json(json!({
|
|
|
|
|
"error": {
|
|
|
|
|
"message": format!(
|
|
|
|
|
"video cancellation upstream returned HTTP {}",
|
|
|
|
|
result.status_code
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn persist_cancelled_video_task(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
task: &StoredVideoTask,
|
|
|
|
|
) -> Result<Option<StoredVideoTask>, GatewayError> {
|
|
|
|
|
let now_unix_secs = current_unix_secs();
|
|
|
|
|
state
|
2026-09-04 03:45:52 +08:00
|
|
|
.update_active_video_task(UpsertVideoTask {
|
2026-04-03 14:59:58 +08:00
|
|
|
id: task.id.clone(),
|
|
|
|
|
short_id: task.short_id.clone(),
|
|
|
|
|
request_id: task.request_id.clone(),
|
|
|
|
|
user_id: task.user_id.clone(),
|
|
|
|
|
api_key_id: task.api_key_id.clone(),
|
|
|
|
|
username: task.username.clone(),
|
|
|
|
|
api_key_name: task.api_key_name.clone(),
|
|
|
|
|
external_task_id: task.external_task_id.clone(),
|
|
|
|
|
provider_id: task.provider_id.clone(),
|
|
|
|
|
endpoint_id: task.endpoint_id.clone(),
|
|
|
|
|
key_id: task.key_id.clone(),
|
|
|
|
|
client_api_format: task.client_api_format.clone(),
|
|
|
|
|
provider_api_format: task.provider_api_format.clone(),
|
|
|
|
|
format_converted: task.format_converted,
|
|
|
|
|
model: task.model.clone(),
|
|
|
|
|
prompt: task.prompt.clone(),
|
2026-09-04 03:45:52 +08:00
|
|
|
original_request_body: None,
|
2026-04-03 14:59:58 +08:00
|
|
|
duration_seconds: task.duration_seconds,
|
|
|
|
|
resolution: task.resolution.clone(),
|
|
|
|
|
aspect_ratio: task.aspect_ratio.clone(),
|
|
|
|
|
size: task.size.clone(),
|
|
|
|
|
status: VideoTaskStatus::Cancelled,
|
|
|
|
|
progress_percent: task.progress_percent,
|
2026-09-04 03:45:52 +08:00
|
|
|
progress_message: None,
|
2026-04-03 14:59:58 +08:00
|
|
|
retry_count: task.retry_count,
|
|
|
|
|
poll_interval_seconds: task.poll_interval_seconds,
|
|
|
|
|
next_poll_at_unix_secs: None,
|
|
|
|
|
poll_count: task.poll_count,
|
|
|
|
|
max_poll_count: task.max_poll_count,
|
2026-04-10 01:46:14 +08:00
|
|
|
created_at_unix_ms: task.created_at_unix_ms,
|
2026-04-03 14:59:58 +08:00
|
|
|
submitted_at_unix_secs: task.submitted_at_unix_secs,
|
|
|
|
|
completed_at_unix_secs: Some(now_unix_secs),
|
|
|
|
|
updated_at_unix_secs: now_unix_secs,
|
|
|
|
|
error_code: task.error_code.clone(),
|
2026-09-04 03:45:52 +08:00
|
|
|
error_message: None,
|
2026-04-03 14:59:58 +08:00
|
|
|
video_url: task.video_url.clone(),
|
2026-09-04 03:45:52 +08:00
|
|
|
request_metadata: None,
|
2026-04-03 14:59:58 +08:00
|
|
|
})
|
|
|
|
|
.await
|
2026-09-04 03:45:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
use aether_contracts::{
|
|
|
|
|
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionResult, ResponseBody,
|
|
|
|
|
};
|
|
|
|
|
use axum::body::to_bytes;
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
|
|
|
|
|
use super::build_video_task_cancel_upstream_error_response;
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn cancellation_upstream_errors_do_not_expose_runtime_payloads() {
|
|
|
|
|
let result = ExecutionResult {
|
|
|
|
|
request_id: "cancel-secret-request-id".to_string(),
|
|
|
|
|
candidate_id: Some("cancel-secret-candidate-id".to_string()),
|
|
|
|
|
status_code: 502,
|
|
|
|
|
headers: BTreeMap::from([(
|
|
|
|
|
"x-internal-secret".to_string(),
|
|
|
|
|
"cancel-secret-header".to_string(),
|
|
|
|
|
)]),
|
|
|
|
|
response_observation: None,
|
|
|
|
|
body: Some(ResponseBody {
|
|
|
|
|
json_body: Some(json!({
|
|
|
|
|
"error": {
|
|
|
|
|
"message": "cancel-secret-upstream-body",
|
|
|
|
|
}
|
|
|
|
|
})),
|
|
|
|
|
body_bytes_b64: None,
|
|
|
|
|
}),
|
|
|
|
|
telemetry: None,
|
|
|
|
|
error: Some(ExecutionError {
|
|
|
|
|
kind: ExecutionErrorKind::Upstream5xx,
|
|
|
|
|
phase: ExecutionPhase::FirstByte,
|
|
|
|
|
message: "cancel-secret-runtime-error".to_string(),
|
|
|
|
|
upstream_status: Some(502),
|
|
|
|
|
retryable: true,
|
|
|
|
|
failover_recommended: false,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let response = build_video_task_cancel_upstream_error_response(&result);
|
|
|
|
|
assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY);
|
|
|
|
|
assert!(response.headers().get("x-internal-secret").is_none());
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response body should read");
|
|
|
|
|
let payload: serde_json::Value =
|
|
|
|
|
serde_json::from_slice(&body).expect("response body should parse");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload,
|
|
|
|
|
json!({
|
|
|
|
|
"error": {
|
|
|
|
|
"message": "video cancellation upstream returned HTTP 502",
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
let body = String::from_utf8(body.to_vec()).expect("response body should be utf-8");
|
|
|
|
|
assert!(!body.contains("cancel-secret"));
|
|
|
|
|
}
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|