feat(all): 重炼 crates/common 核心组件、上线 Web 运维看板与 Docker 容器化部署

This commit is contained in:
fmq
2026-07-28 10:31:57 +08:00
commit 4b4238d702
71 changed files with 163402 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
pub mod sqlite_queue;
+245
View File
@@ -0,0 +1,245 @@
use anyhow::{Context, Result};
use common::models::TaskSpec;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::params;
use tracing::info;
#[derive(Debug)]
struct SqliteCustomizer;
impl r2d2::CustomizeConnection<rusqlite::Connection, rusqlite::Error> for SqliteCustomizer {
fn on_acquire(&self, conn: &mut rusqlite::Connection) -> Result<(), rusqlite::Error> {
conn.pragma_update(None, "busy_timeout", 5000)?;
Ok(())
}
}
#[derive(Clone)]
pub struct SqliteTaskQueue {
pool: Pool<SqliteConnectionManager>,
}
impl SqliteTaskQueue {
pub async fn new(db_path: &str) -> Result<Self> {
let db_path_owned = db_path.to_string();
let pool = tokio::task::spawn_blocking(move || -> Result<Pool<SqliteConnectionManager>> {
if let Some(parent) = std::path::Path::new(&db_path_owned).parent() {
let _ = std::fs::create_dir_all(parent);
}
let manager = SqliteConnectionManager::file(&db_path_owned);
let pool = Pool::builder()
.max_size(4)
.connection_customizer(Box::new(SqliteCustomizer))
.build(manager)
.context("Failed to build SQLite queue connection pool")?;
let conn = pool.get()?;
let _: String = conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get(0))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS task_queue (
task_id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
status TEXT NOT NULL,
created_at DATETIME NOT NULL,
claimed_at DATETIME
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)",
[],
)?;
Ok(pool)
})
.await??;
info!("成功初始化 SQLite 任务队列数据库连接池: {}", db_path);
Ok(Self { pool })
}
pub async fn push_task(&self, task: &TaskSpec) -> Result<()> {
let payload = serde_json::to_string(task)?;
let task_id_str = task.task_id.to_string();
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute(
"INSERT OR REPLACE INTO task_queue (task_id, payload, status, created_at)
VALUES (?1, ?2, 'pending', datetime('now'))",
params![task_id_str, payload],
)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn pop_task(&self) -> Result<Option<TaskSpec>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Option<TaskSpec>> {
let mut attempts = 0;
loop {
let mut conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
let tx_res = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate);
match tx_res {
Ok(tx) => {
let mut stmt = tx.prepare(
"SELECT task_id, payload FROM task_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
)?;
let row = stmt.query_row([], |row| {
let id: String = row.get(0)?;
let payload: String = row.get(1)?;
Ok((id, payload))
});
drop(stmt);
let (task_id, payload) = match row {
Ok(res) => res,
Err(rusqlite::Error::QueryReturnedNoRows) => {
return Ok(None);
}
Err(e) => return Err(e.into()),
};
let task: TaskSpec = serde_json::from_str(&payload)?;
tx.execute(
"UPDATE task_queue SET status = 'claimed', claimed_at = datetime('now') WHERE task_id = ?1",
params![task_id],
)?;
tx.commit()?;
return Ok(Some(task));
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::DatabaseBusy
|| err.code == rusqlite::ErrorCode::DatabaseLocked =>
{
attempts += 1;
if attempts >= 5 {
anyhow::bail!("Queue DB busy/locked after 5 retries: {}", err);
}
std::thread::sleep(std::time::Duration::from_millis(10 * (1 << attempts)));
}
Err(e) => return Err(e.into()),
}
}
})
.await?
}
pub async fn remove_task(&self, task_id: &str) -> Result<()> {
let pool = self.pool.clone();
let id_owned = task_id.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute("DELETE FROM task_queue WHERE task_id = ?1", params![id_owned])?;
Ok(())
})
.await??;
Ok(())
}
pub async fn requeue_stale_tasks(&self, stale_sec: u64) -> Result<Vec<String>> {
let pool = self.pool.clone();
let names = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let mut conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
let tx = conn.transaction()?;
let mut point_names = Vec::new();
{
// 改写为单一原子更新带 RETURNING 返回语句,消弭 TOCTOU (Time-Of-Check-To-Time-Of-Use) 竞态问题
let mut stmt = tx.prepare(
"UPDATE task_queue SET status = 'pending', claimed_at = NULL
WHERE status = 'claimed' AND strftime('%s', 'now') - strftime('%s', claimed_at) >= ?1
RETURNING payload",
)?;
let rows = stmt.query_map(params![stale_sec as i64], |row| row.get::<_, String>(0))?;
for r in rows {
if let Ok(payload) = r {
if let Ok(task) = serde_json::from_str::<TaskSpec>(&payload) {
point_names.push(task.point_name);
}
}
}
}
tx.commit()?;
Ok(point_names)
})
.await??;
Ok(names)
}
pub async fn clear_queue(&self) -> Result<()> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute("DELETE FROM task_queue", [])?;
Ok(())
})
.await??;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::models::{GridPointParams, TaskType};
use uuid::Uuid;
#[tokio::test]
async fn test_sqlite_task_queue_operations() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("test_queue.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy()).await.unwrap();
assert!(queue.pop_task().await.unwrap().is_none());
let task_id = Uuid::new_v4();
let task = TaskSpec {
task_id,
point_name: "t35000_g5.5_he-1_c-2_n-2_o-2".to_string(),
params: GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 3600,
};
queue.push_task(&task).await.unwrap();
let popped = queue.pop_task().await.unwrap();
assert!(popped.is_some());
let popped_task = popped.unwrap();
assert_eq!(popped_task.task_id, task_id);
assert_eq!(popped_task.point_name, task.point_name);
assert!(queue.pop_task().await.unwrap().is_none());
let requeued = queue.requeue_stale_tasks(0).await.unwrap();
assert_eq!(requeued.len(), 1);
let popped2 = queue.pop_task().await.unwrap();
assert!(popped2.is_some());
queue.remove_task(&task_id.to_string()).await.unwrap();
assert!(queue.pop_task().await.unwrap().is_none());
}
}