feat: 加固运行期可靠性并补齐调度与观测接口
- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态 - 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected - 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule - SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB - 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
This commit is contained in:
+148
-2
@@ -573,7 +573,12 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
&working_dir,
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
vec!["-File", &script_path.to_string_lossy()],
|
||||
vec![
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
&script_path.to_string_lossy(),
|
||||
],
|
||||
"0 0 2 * * *",
|
||||
1,
|
||||
);
|
||||
@@ -603,7 +608,10 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
assert_eq!(second_run["status"], "skipped");
|
||||
|
||||
let completed = wait_for_run(&app.router, first_run_id).await;
|
||||
assert_eq!(completed["status"], "timed_out");
|
||||
assert_eq!(
|
||||
completed["status"], "timed_out",
|
||||
"unexpected run: {completed}"
|
||||
);
|
||||
assert!(
|
||||
completed["error_message"]
|
||||
.as_str()
|
||||
@@ -821,3 +829,141 @@ async fn long_utf8_log_is_reassembled_across_chunk_boundaries() {
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_endpoint_aggregates_task_counts() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let script = script_task(
|
||||
"stats_script",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let http = http_task("http://127.0.0.1:9/never");
|
||||
for payload in [script.clone(), http.clone()] {
|
||||
let response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
let response = send_json(&app.router, Method::GET, "/api/v1/stats", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let stats = response_json(response).await;
|
||||
|
||||
assert_eq!(stats["total"], 2);
|
||||
assert_eq!(stats["enabled"], 2);
|
||||
assert_eq!(stats["script"], 1);
|
||||
assert_eq!(stats["http"], 1);
|
||||
assert_eq!(stats["process"], 0);
|
||||
assert_eq!(stats["running"], 0);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_endpoint_reports_next_run_for_enabled_tasks() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"scheduled_stats",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"*/30 * * * * *",
|
||||
5,
|
||||
);
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
let response = send_json(&app.router, Method::GET, "/api/v1/schedule", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let schedule = response_json(response).await;
|
||||
let entries = schedule.as_array().expect("schedule should be an array");
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|item| item["task_id"] == "scheduled_stats")
|
||||
.expect("enabled task should appear in the schedule");
|
||||
|
||||
assert!(
|
||||
entry["next_run_at"].is_string(),
|
||||
"next_run_at should be computed for a registered task"
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_tasks_are_registered_for_scheduling() {
|
||||
let app = test_app().await;
|
||||
let task = http_task("http://127.0.0.1:9/never");
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
assert!(
|
||||
app.state.scheduler.is_scheduled("http_task"),
|
||||
"HTTP tasks must take part in scheduled runs"
|
||||
);
|
||||
|
||||
let disabled = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/http_task/disable",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(disabled.status(), StatusCode::OK);
|
||||
assert!(!app.state.scheduler.is_scheduled("http_task"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_marks_runs_interrupted_by_restart_as_cancelled() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"interrupted_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
// 模拟上一次进程被强杀后遗留的运行记录。
|
||||
sqlx::query(
|
||||
"INSERT INTO task_runs (task_id, trigger_type, status, created_at)
|
||||
VALUES ('interrupted_task', 'manual', 'running', ?)",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(&app.state.pool)
|
||||
.await
|
||||
.expect("stale run should be inserted");
|
||||
|
||||
let reconciled = task_pilot_backend::runs::repository::fail_interrupted_runs(&app.state.pool)
|
||||
.await
|
||||
.expect("reconciliation should run");
|
||||
assert_eq!(reconciled, 1);
|
||||
|
||||
let runs = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/interrupted_task/runs",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let body = response_json(runs).await;
|
||||
let item = &body["items"][0];
|
||||
assert_eq!(item["status"], "cancelled");
|
||||
assert!(
|
||||
item["error_message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("重启")
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user