feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use crate::{
error::{ApiError, ApiResult},
tasks::model::{
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
TaskListResponse, TaskResponse, UpdateTaskRequest,
},
};
const TASK_COLUMNS: &str = "
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
";
pub struct ListFilters {
pub q: Option<String>,
pub enabled: Option<bool>,
pub action_type: Option<TaskActionType>,
pub page: u32,
pub page_size: u32,
}
#[derive(Debug, FromRow)]
struct TaskRow {
id: String,
name: String,
cron: String,
timezone: String,
enabled: bool,
timeout_secs: i64,
concurrency_policy: String,
retry_max_attempts: i64,
retry_delay_secs: i64,
action_type: String,
action_json: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl TryFrom<TaskRow> for TaskResponse {
type Error = ApiError;
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
if row.concurrency_policy != "skip" {
return Err(ApiError::internal(format!(
"unknown concurrency policy: {}",
row.concurrency_policy
)));
}
let action: TaskAction = serde_json::from_str(&row.action_json)?;
if row.action_type != action.action_type().as_str() {
return Err(ApiError::internal(format!(
"task {} action type does not match action payload",
row.id
)));
}
Ok(Self {
id: row.id,
name: row.name,
cron: row.cron,
timezone: row.timezone,
enabled: row.enabled,
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy {
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
},
action,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}
pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult<TaskResponse> {
let now = Utc::now();
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"INSERT INTO tasks (
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&request.id)
.bind(&request.name)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
.bind(now)
.execute(pool)
.await;
if let Err(error) = result {
if is_unique_violation(&error) {
return Err(ApiError::conflict(format!(
"任务标识 {} 已存在",
request.id
)));
}
return Err(error.into());
}
Ok(TaskResponse {
id: request.id.clone(),
name: request.name.clone(),
cron: request.cron.clone(),
timezone: request.timezone.clone(),
enabled: request.enabled,
timeout_secs: request.timeout_secs,
concurrency_policy: request.concurrency_policy,
retry_policy: request.retry_policy.clone(),
action: request.action.clone(),
created_at: now,
updated_at: now,
})
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
let row = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
FROM tasks
WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
row.map(TaskResponse::try_from).transpose()
}
pub async fn update(
pool: &SqlitePool,
id: &str,
request: &UpdateTaskRequest,
) -> ApiResult<TaskResponse> {
let now = Utc::now();
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"UPDATE tasks SET
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
action_type = ?, action_json = ?, updated_at = ?
WHERE id = ?",
)
.bind(&request.name)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found(format!("任务 {id} 不存在")));
}
get(pool, id)
.await?
.ok_or_else(|| ApiError::not_found(format!("任务 {id} 不存在")))
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<bool> {
let result = sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<bool> {
let result = sqlx::query("UPDATE tasks SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(Utc::now())
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskListResponse> {
let mut count_builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM tasks");
apply_filters(&mut count_builder, filters);
let total: i64 = count_builder.build_query_scalar().fetch_one(pool).await?;
let offset = i64::from(filters.page.saturating_sub(1)) * i64::from(filters.page_size);
let mut list_builder = QueryBuilder::<Sqlite>::new(format!("SELECT {TASK_COLUMNS} FROM tasks"));
apply_filters(&mut list_builder, filters);
list_builder
.push(" ORDER BY updated_at DESC, id ASC LIMIT ")
.push_bind(i64::from(filters.page_size))
.push(" OFFSET ")
.push_bind(offset);
let rows = list_builder
.build_query_as::<TaskRow>()
.fetch_all(pool)
.await?;
let items = rows
.into_iter()
.map(TaskResponse::try_from)
.collect::<ApiResult<Vec<_>>>()?;
Ok(TaskListResponse {
items,
page: filters.page,
page_size: filters.page_size,
total: u64::try_from(total).map_err(ApiError::internal)?,
})
}
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
let rows = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
FROM tasks
WHERE enabled = 1 AND action_type = 'script'
ORDER BY updated_at DESC, id ASC",
)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(TaskResponse::try_from)
.collect::<ApiResult<Vec<_>>>()
}
fn apply_filters(builder: &mut QueryBuilder<Sqlite>, filters: &ListFilters) {
builder.push(" WHERE 1 = 1");
if let Some(search) = &filters.q {
let pattern = format!(
"%{}%",
search
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
builder
.push(" AND (id LIKE ")
.push_bind(pattern.clone())
.push(" ESCAPE '\\' OR name LIKE ")
.push_bind(pattern)
.push(" ESCAPE '\\')");
}
if let Some(enabled) = filters.enabled {
builder.push(" AND enabled = ").push_bind(enabled);
}
if let Some(action_type) = filters.action_type {
builder
.push(" AND action_type = ")
.push_bind(action_type.as_str());
}
}
fn is_unique_violation(error: &sqlx::Error) -> bool {
matches!(error, sqlx::Error::Database(database_error) if database_error.is_unique_violation())
}