summaryrefslogtreecommitdiff
path: root/server/src/jobs.rs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /server/src/jobs.rs
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'server/src/jobs.rs')
-rw-r--r--server/src/jobs.rs857
1 files changed, 857 insertions, 0 deletions
diff --git a/server/src/jobs.rs b/server/src/jobs.rs
new file mode 100644
index 0000000..a3c49eb
--- /dev/null
+++ b/server/src/jobs.rs
@@ -0,0 +1,857 @@
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::Duration;
+
+use tokio::sync::Mutex;
+use std::collections::HashSet;
+use serde_json::{json, Value};
+
+use crate::auth::audit;
+use crate::error::AppError;
+use crate::state::AppState;
+use crate::store::ObjectStore;
+use crate::util::{canonical_json, new_id, now_iso, sha256_hex};
+
+pub struct Signals;
+
+impl Signals {
+ pub fn new() -> Self { Signals }
+}
+
+pub async fn main_loop(cx: Arc<AppState>, _signals: Signals) {
+ loop {
+ if let Err(e) = tick(&cx).await { tracing::error!("job loop: {e}"); }
+ tokio::time::sleep(Duration::from_millis(700)).await;
+ }
+}
+
+// ---- shared in-flight fetch serialization for the exact request cache key ----
+
+fn inflight() -> &'static Mutex<HashSet<String>> {
+ static SET: std::sync::OnceLock<Mutex<HashSet<String>>> = std::sync::OnceLock::new();
+ SET.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+pub fn cache_key_for_request(req: &Value) -> String {
+ sha256_hex(canonical_json(req).as_bytes())
+}
+
+async fn release_key(key: &str) {
+ inflight().lock().await.remove(key);
+}
+
+// ---- dispatcher ----
+
+async fn tick(cx: &Arc<AppState>) -> Result<(), AppError> {
+ // pending datasets -> bounded fetch concurrency
+ let n_running: i64 = cx.with_db(|db| {
+ db.query_row("SELECT COUNT(*) FROM datasets WHERE status='running'", [], |r| r.get(0))
+ }).await.unwrap_or(0);
+ if n_running < cx.cfg.fetch_concurrency as i64 {
+ let next: Option<(String, String)> = cx.with_db(|db| {
+ db.query_row("SELECT id,request FROM datasets WHERE status='pending' ORDER BY created_at LIMIT 1", [],
+ |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).ok()
+ }).await;
+ if let Some((did, request)) = next {
+ cx.with_db(|db| {
+ db.execute("UPDATE datasets SET status='running', updated_at=?1 WHERE id=?2 AND status='pending'",
+ rusqlite::params![now_iso(), &did]).ok();
+ }).await;
+ let cx2 = cx.clone();
+ tokio::spawn(async move {
+ if let Err(e) = spawn_fetch_job(&cx2, &did, &request).await {
+ tracing::error!("fetch {did}: {e}");
+ cx2.with_db(|db| {
+ db.execute("UPDATE datasets SET status='failed', error=?1, updated_at=?2 WHERE id=?3 AND status IN ('pending','running')",
+ rusqlite::params![e.message, now_iso(), &did]).ok();
+ }).await;
+ }
+ });
+ }
+ }
+
+ // queued runs -> single active backtest worker (POC)
+ let n_running_runs: i64 = cx.with_db(|db| {
+ db.query_row("SELECT COUNT(*) FROM runs WHERE status='running'", [], |r| r.get(0))
+ }).await.unwrap_or(0);
+ if n_running_runs >= cx.cfg.run_concurrency as i64 { return Ok(()); }
+ let next: Option<(String, String)> = cx.with_db(|db| {
+ db.query_row("SELECT id,config FROM runs WHERE status='queued' ORDER BY created_at LIMIT 1", [],
+ |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).ok()
+ }).await;
+ let Some((run_id, config)) = next else { return Ok(()); };
+ let container_name = format!("sl-run-{}", new_id());
+ // Atomic claim: queued -> running WITH container_id set in the same transaction.
+ // A run never becomes 'running' without a container identity, so the claim
+ // itself proves the worker task was handed off.
+ let claimed = cx.with_db(|db| -> Result<bool, AppError> {
+ move_claim(&mut *db, &run_id, &container_name)
+ }).await?;
+ if !claimed { return Ok(()); }
+ let cx2 = cx.clone();
+ tokio::spawn(async move {
+ let user_id: String = cx2.with_db(|db| {
+ db.query_row("SELECT user_id FROM runs WHERE id=?1", [&run_id], |r| r.get(0)).unwrap_or_default()
+ }).await;
+ let res = run_backtest(&cx2, &run_id, &config, &container_name).await;
+ let _ = finalize_run(&cx2, &run_id, &user_id, res).await;
+ });
+ Ok(())
+}
+
+/// Recheck account/quota/dataset durability, then claim queued -> running with
+/// a container id atomically inside one transaction.
+/// Returns true only if this caller actually claimed the run (and must spawn the worker).
+fn move_claim(db: &mut rusqlite::Connection, run_id: &str, container_id: &str) -> Result<bool, AppError> {
+ db.execute("BEGIN IMMEDIATE", []).ok();
+ match db.query_row(
+ "SELECT u.active, r.user_id FROM runs r JOIN users u ON u.id=r.user_id WHERE r.id=?1",
+ [run_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) {
+ Ok((active, _uid)) if active == 1 => {},
+ _ => {
+ db.execute("UPDATE runs SET status='failed', error='account disabled', finished_at=?1 WHERE id=?2 AND status='queued'",
+ rusqlite::params![now_iso(), run_id]).ok();
+ db.execute("COMMIT", []).ok();
+ return Ok(false);
+ }
+ }
+ let uid = db.query_row("SELECT user_id FROM runs WHERE id=?1", [run_id], |r| r.get::<_, String>(0)).unwrap_or_default();
+ let ready_ok: Option<i64> = db.query_row(
+ "SELECT d.status='ready' AS ok FROM datasets d JOIN runs r ON r.dataset_id=d.id AND r.user_id=d.user_id WHERE r.id=?1",
+ [run_id], |r| r.get::<_, i64>(0)).ok();
+ if ready_ok != Some(1) {
+ db.execute("UPDATE runs SET status='failed', error='dataset no longer ready at dequeue', finished_at=?1 WHERE id=?2 AND status='queued'",
+ rusqlite::params![now_iso(), run_id]).ok();
+ db.execute("COMMIT", []).ok();
+ return Ok(false);
+ }
+ let today = format!("{}%", chrono::Utc::now().format("%Y-%m-%d").to_string());
+ let used: i64 = db.query_row("SELECT COUNT(*) FROM runs WHERE user_id=?1 AND created_at LIKE ?2",
+ rusqlite::params![&uid, &today], |r| r.get(0)).unwrap_or(0);
+ let limit: i64 = db.query_row("SELECT daily_run_limit FROM users WHERE id=?1", [&uid], |r| r.get(0)).unwrap_or(10);
+ if used > limit {
+ db.execute("UPDATE runs SET status='failed', error='run quota exceeded', finished_at=?1 WHERE id=?2 AND status='queued'",
+ rusqlite::params![now_iso(), run_id]).ok();
+ db.execute("COMMIT", []).ok();
+ return Ok(false);
+ }
+ // Single atomic transition: a claimed run is running WITH its container identity.
+ let n = db.execute(
+ "UPDATE runs SET status='running', started_at=?1, container_id=?2 WHERE id=?3 AND status='queued'",
+ rusqlite::params![now_iso(), container_id, run_id]).unwrap_or(0);
+ db.execute("COMMIT", []).ok();
+ Ok(n == 1)
+}
+
+async fn finalize_run(cx: &Arc<AppState>, run_id: &str, user_id: &str, res: Result<Value, AppError>) {
+ cx.with_db(|db| -> Result<(), AppError> {
+ match res {
+ Ok(result) => {
+ let n = db.execute("UPDATE runs SET status='succeeded', result=?1, error=NULL, finished_at=?2 WHERE id=?3 AND status='running'",
+ rusqlite::params![result.to_string(), now_iso(), run_id]).unwrap_or(0);
+ if n > 0 {
+ let cxx = cx.clone();
+ let uid = user_id.to_string();
+ let rid = run_id.to_string();
+ tokio::spawn(async move { audit(&axum::extract::State(cxx), Some(&uid), "run_succeeded", &rid, "ok").await });
+ }
+ }
+ Err(e) => {
+ // guaranteed non-empty terminal reason, never an empty failure
+ let msg = if e.message.trim().is_empty() { "worker failed without error detail".to_string() } else { e.message.clone() };
+ db.execute("UPDATE runs SET status='failed', error=?1, finished_at=?2 WHERE id=?3 AND status='running'",
+ rusqlite::params![msg, now_iso(), run_id]).ok();
+ }
+ }
+ Ok(())
+ }).await.ok();
+}
+
+// ---- dataset fetch ----
+
+async fn try_cache(cx: &Arc<AppState>, dataset_id: &str, key: &str) -> Result<bool, AppError> {
+ let manifest: Option<String> = cx.with_db(|db| {
+ db.query_row("SELECT manifest FROM fetch_cache WHERE key=?1", [key], |r| r.get(0)).ok()
+ }).await;
+ let Some(m) = manifest else { return Ok(false); };
+ let mv: Value = serde_json::from_str(&m).unwrap_or(Value::Null);
+ let hash = mv.get("hash").and_then(|v| v.as_str()).unwrap_or_default().to_string();
+ cx.with_db(|db| {
+ db.execute("UPDATE datasets SET status='ready', error=NULL, manifest=?1, manifest_hash=?2, cache_hit=1, updated_at=?3 WHERE id=?4 AND status='running'",
+ rusqlite::params![&m, hash, now_iso(), dataset_id]).ok();
+ }).await;
+ Ok(true)
+}
+
+/// One dataset fetch: cache recheck or container run + artifact ingest.
+pub async fn spawn_fetch_job(cx: &Arc<AppState>, dataset_id: &str, request_s: &str) -> Result<(), AppError> {
+ let request: Value = serde_json::from_str(request_s).map_err(|e| AppError::internal(format!("stored request invalid: {e}")))?;
+ let key = cache_key_for_request(&request);
+
+ // serialize identical cache keys: second arrives late and rechecks cache
+ let deadline = std::time::Duration::from_secs(cx.cfg.fetch_timeout_secs.max(60));
+ let started = std::time::Instant::now();
+ loop {
+ {
+ let mut set = inflight().lock().await;
+ if set.insert(key.clone()) { break; }
+
+ }
+ if try_cache(cx, dataset_id, &key).await? { return Ok(()); }
+ if started.elapsed() > deadline {
+ return Err(AppError::internal("waiting on identical in-flight fetch timed out"));
+ }
+ tokio::time::sleep(Duration::from_millis(1500)).await;
+ }
+ let result = perform_fetch(cx, dataset_id, request, &key).await;
+ release_key(&key).await;
+ result
+}
+
+async fn perform_fetch(cx: &Arc<AppState>, dataset_id: &str, request: Value, key: &str) -> Result<(), AppError> {
+ // cached from a prior identical request?
+ if try_cache(cx, dataset_id, key).await? { return Ok(()); }
+
+ let store = ObjectStore::new(&cx.cfg.data_dir);
+ let work = temp_job_dir(&cx.cfg.data_dir)?;
+ let input = work.join("input");
+ let output = work.join("output");
+ std::fs::create_dir_all(&input).map_err(io_err)?;
+ std::fs::create_dir_all(&output).map_err(io_err)?;
+ open_writable(&output)?;
+
+ std::fs::write(input.join("request.json"), request.to_string()).map_err(io_err)?;
+ let mounts = vec![
+ (input.display().to_string(), "/input".to_string(), true),
+ (output.display().to_string(), "/output".to_string(), false),
+ ];
+ let args = vec![
+ "python".into(), "-m".into(), "worker.main".into(), "fetch".into(),
+ "--request".into(), "/input/request.json".into(), "--output".into(), "/output".into(),
+ ];
+ let name = format!("sl-fetch-{}", new_id());
+ let res = crate::worker::run_named(&*cx, true, &mounts, &args, &name, cx.cfg.fetch_timeout_secs).await;
+ let out_take = match res {
+ Ok(r) if r.ok() => r,
+ Ok(r) => {
+ let msg = worker_error_message(&output, r.stderr.as_str());
+ cleanup(&work);
+ return Err(AppError::bad("fetch_failed", msg));
+ }
+ Err(e) => { cleanup(&work); return Err(e); }
+ };
+
+ let build = ingest_fetch_output(&store, &work).await;
+ let (manifest, _warnings) = match build {
+ Ok(v) => v,
+ Err(e) => { cleanup(&work); return Err(e); }
+ };
+ let _ = out_take;
+ let manifest_hash = manifest.get("hash").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ if manifest_hash.is_empty() || manifest.get("objects").and_then(|o| o.as_array()).map(|a| a.is_empty()).unwrap_or(true) {
+ cleanup(&work);
+ return Err(AppError::internal("fetch produced no usable objects"));
+ }
+ let manifest_s = manifest.to_string();
+ let object_count = manifest.get("objects").and_then(|o| o.as_array()).map(|a| a.len()).unwrap_or(0) as i64;
+ cx.with_db(|db| -> Result<(), AppError> {
+ db.execute("INSERT OR REPLACE INTO fetch_cache (key,manifest_hash,manifest,object_count,fetched_at) VALUES (?1,?2,?3,?4,?5)",
+ rusqlite::params![&key, &manifest_hash, &manifest_s, object_count, now_iso()])?;
+ db.execute("UPDATE datasets SET status='ready', error=NULL, manifest=?1, manifest_hash=?2, cache_hit=0, updated_at=?3 WHERE id=?4 AND status='running'",
+ rusqlite::params![&manifest_s, &manifest_hash, now_iso(), dataset_id])?;
+ Ok(())
+ }).await?;
+ cleanup(&work);
+ tracing::info!("fetch {dataset_id} cached under {key} ({object_count} objects)");
+ Ok(())
+}
+
+
+// manifest hash must be content identity: independent of user/request ids and fetch timestamp
+fn manifest_content_hash(m: &Value) -> String {
+ let mut objects = Vec::new();
+ for o in m.get("objects").and_then(|v| v.as_array()).unwrap_or(&vec![]).iter() {
+ let mut e = o.clone();
+ if let Some(map) = e.as_object_mut() {
+ map.remove("fetched_at");
+ map.remove("path");
+ }
+ objects.push(e);
+ }
+ let payload = json!({
+ "frequency": m.get("frequency"),
+ "adjustment": m.get("adjustment"),
+ "schema_version": m.get("schema_version"),
+ "normalization_version": m.get("normalization_version"),
+ "objects": objects,
+ });
+ sha256_hex(canonical_json(&payload).as_bytes())
+}
+
+
+async fn ingest_fetch_output(store: &ObjectStore, work: &Path) -> Result<(Value, Vec<String>), AppError> {
+ let out_dir = work.join("output");
+ let result_path = out_dir.join("result.json");
+ let result: Value = serde_json::from_str(&std::fs::read_to_string(&result_path).map_err(|e| AppError::internal(format!("worker result.json unreadable: {e}")))?)
+ .map_err(|e| AppError::internal(format!("worker result.json invalid: {e}")))?;
+ if result.get("status").and_then(|v| v.as_str()) != Some("ready") {
+ return Err(AppError::bad("fetch_failed", format!("worker reported status: {}", result.get("status").and_then(|v| v.as_str()).unwrap_or("missing"))));
+ }
+ let m = result.get("manifest").cloned().unwrap_or(Value::Null);
+ if !m.get("objects").and_then(|o| o.as_array()).map(|a| !a.is_empty()).unwrap_or(false) {
+ return Err(AppError::bad("fetch_failed", "worker manifest has no objects"));
+ }
+ // hash+ingest every worker artifact once (raw JSON and normalized CSVs)
+ let files: Vec<crate::store::StoredObject> = store.ingest_directory(&out_dir).map_err(io_err)?;
+ let find_hash = |h: &str| files.iter().find(|f| f.hash == h);
+ let find_rel = |rel: &str| files.iter().find(|f| f.stored_path == rel || f.mount_name == rel);
+
+ let all_warnings: Vec<String> = result.get("warnings").and_then(|v| v.as_array()).map(|a| {
+ a.iter().filter_map(|w| w.as_str().map(String::from)).collect()
+ }).unwrap_or_default();
+ let mut new_objects = Vec::new();
+ for obj in m.get("objects").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
+ let mut entry = obj.clone();
+ // normalized: rewrite the internal path into immutable object storage
+ let rel_norm = entry.get("path").and_then(|v| v.as_str()).unwrap_or_default().to_string();
+ if rel_norm.contains("..") {
+ return Err(AppError::internal("worker output path rejected"));
+ }
+ let norm_obj = find_rel(&rel_norm).ok_or_else(|| AppError::internal("normalized object missing from worker output"))?;
+ let rel_stored = norm_obj.stored_path.clone();
+ if let Some(map) = entry.as_object_mut() {
+ map.insert("path".into(), json!(format!("objects/{rel_stored}")));
+ }
+ // raw: locate by content hash of the raw object (worker writes immutable raw JSON first)
+ let raw_hash = entry.get("raw_object_hash").and_then(|v| v.as_str()).unwrap_or_default().to_string();
+ if !raw_hash.is_empty() {
+ if find_hash(&raw_hash).is_none() {
+ return Err(AppError::internal("raw object referenced by manifest is missing"));
+ }
+ }
+ new_objects.push(entry);
+ }
+
+ let mut manifest = m.clone();
+ {
+ let map = manifest.as_object_mut().unwrap();
+ map.insert("objects".into(), Value::Array(new_objects));
+ map.insert("warnings".into(), json!(all_warnings.clone()));
+ map.insert("immutable".into(), json!(true));
+ }
+ // stable content identity independent of request/user ids and fetch timestamp
+ {
+ let h = manifest_content_hash(&manifest);
+ if let Some(map) = manifest.as_object_mut() {
+ map.insert("hash".into(), json!(h));
+ // Per-instrument coverage rows aligned to the client CoverageEntry shape:
+ // {instrument, market, asset_type, requested_start, requested_end, actual_start, actual_end, row_count, warnings}
+ let coverage: Vec<Value> = map.get("objects").and_then(|o| o.as_array()).map(|objs| {
+ objs.iter().map(|o| {
+ let inst = o.get("instrument").cloned().unwrap_or(Value::Null);
+ json!({
+ "instrument": inst.get("symbol").or_else(|| inst.get("instrument")).and_then(|v| v.as_str())
+ .map(String::from).unwrap_or_default(),
+ "market": inst.get("market").cloned().unwrap_or(Value::Null),
+ "asset_type": inst.get("asset_type").cloned().unwrap_or(Value::Null),
+ "requested_start": o.get("requested_start").cloned().unwrap_or(Value::Null),
+ "requested_end": o.get("requested_end").cloned().unwrap_or(Value::Null),
+ "actual_start": o.get("actual_start").cloned().unwrap_or(Value::Null),
+ "actual_end": o.get("actual_end").cloned().unwrap_or(Value::Null),
+ "row_count": o.get("row_count").cloned().unwrap_or(Value::Null),
+ "warnings": o.get("warnings").cloned().unwrap_or(json!([])),
+ })
+ }).collect()
+ }).unwrap_or_default();
+ let fallback_preview = json!({"columns": [], "rows": [], "coverage": coverage, "warnings": all_warnings.clone()});
+ let pv = match result.get("preview") {
+ Some(p) => {
+ let mut p = p.clone();
+ p["warnings"] = json!(all_warnings.clone());
+ if !p.get("coverage").map(|c| c.is_array()).unwrap_or(false) {
+ p["coverage"] = json!(coverage);
+ }
+ p
+ }
+ None => fallback_preview,
+ };
+ map.insert("preview".into(), pv);
+ }
+ }
+ Ok((manifest, all_warnings))
+}
+
+// ---- backtest ----
+
+pub async fn run_backtest(cx: &Arc<AppState>, run_id: &str, config_s: &str, container_name: &str) -> Result<Value, AppError> {
+ let (code, dataset_id): (String, String) = cx.with_db(|db| {
+ db.query_row("SELECT pv.code, r.dataset_id FROM runs r JOIN project_versions pv ON pv.id=r.version_id WHERE r.id=?1",
+ [run_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
+ .map_err(|_| AppError::not_found("run not found"))
+ }).await?;
+ let manifest: Value = cx.with_db(|db| {
+ let m: String = db.query_row("SELECT manifest FROM datasets WHERE id=?1 AND status='ready'", [&dataset_id], |r| r.get(0))
+ .map_err(|_| AppError::conflict("dataset_not_ready", "dataset not ready"))?;
+ serde_json::from_str(&m).map_err(|e| AppError::internal(format!("manifest corrupt: {e}")))
+ }).await?;
+ let store = ObjectStore::new(&cx.cfg.data_dir);
+
+ let work = temp_job_dir(&cx.cfg.data_dir)?;
+ let input = work.join("input");
+ let output = work.join("output");
+ let r = (|| -> Result<(String, Vec<(String, String, bool)>), AppError> {
+ std::fs::create_dir_all(&input).map_err(io_err)?;
+ std::fs::create_dir_all(&output).map_err(io_err)?;
+ open_writable(&output)?;
+ let mut run_manifest = manifest.clone();
+ let mut mounts: Vec<(String, String, bool)> = Vec::new();
+ if let Some(objs) = run_manifest.get_mut("objects").and_then(|o| o.as_array_mut()) {
+ for obj in objs.iter_mut() {
+ let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or_default().to_string();
+ // internal layout: objects/<hash-prefix>/<object>; reject traversal/symlinks
+ let rel = path.strip_prefix("objects/").unwrap_or("");
+ if rel.is_empty() || rel.contains("..") || rel.contains('/') && rel.len() < 4 {
+ return Err(AppError::internal("invalid stored object path"));
+ }
+ let abs = store.absolute(rel);
+ if abs.is_symlink() || !abs.is_file() {
+ return Err(AppError::internal("object file missing or not a regular file"));
+ }
+ let fname = abs.file_name().and_then(|f| f.to_str()).unwrap_or_default().to_string();
+ let dst = format!("/data/{fname}");
+ obj.as_object_mut().unwrap().insert("path".into(), json!(dst.clone()));
+ mounts.push((abs.display().to_string(), dst, true));
+ }
+ }
+ if run_manifest.get("objects").and_then(|o| o.as_array()).map(|a| a.is_empty()).unwrap_or(true) {
+ return Err(AppError::internal("dataset manifest has no mountable objects"));
+ }
+ std::fs::write(input.join("request.json"), json!({
+ "code": code.clone(),
+ "config": serde_json::from_str::<Value>(config_s).unwrap_or(Value::Null),
+ "dataset_manifest": run_manifest,
+ "data_root": "/data",
+ }).to_string()).map_err(io_err)?;
+ mounts.push((input.display().to_string(), "/input".into(), true));
+ mounts.push((output.display().to_string(), "/output".into(), false));
+ Ok((code, mounts))
+ })();
+ let (_code_owned, mounts) = match r {
+ Ok(v) => v,
+ Err(e) => { cleanup(&work); return Err(e); }
+ };
+ let args = vec![
+ "python".into(), "-m".into(), "worker.main".into(), "backtest".into(),
+ "--request".into(), "/input/request.json".into(), "--output".into(), "/output".into(),
+ ];
+ let res = crate::worker::run_named(&*cx, false, &mounts, &args, container_name, cx.cfg.backtest_timeout_secs).await;
+ let mut result: Value = match res {
+ Ok(r) if r.ok() => {
+ match std::fs::read_to_string(output.join("result.json")) {
+ Ok(s) => serde_json::from_str(&s).map_err(|e| AppError::internal(format!("backtest result invalid: {e}")))?,
+ Err(e) => { cleanup(&work); return Err(AppError::internal(format!("backtest result unreadable: {e}"))); }
+ }
+ }
+ Ok(r) => {
+ let msg = worker_error_message(&output, r.stderr.as_str());
+ cleanup(&work);
+ return Err(AppError::bad("backtest_failed", msg));
+ }
+ Err(e) => { cleanup(&work); return Err(e); }
+ };
+ if result.get("status").and_then(|v| v.as_str()) != Some("succeeded") {
+ let msg = result.get("error").and_then(|v| v.as_str()).map(String::from)
+ .unwrap_or_else(|| format!("worker status: {}", result.get("status").and_then(|v| v.as_str()).unwrap_or("missing")));
+ cleanup(&work);
+ return Err(AppError::bad("backtest_failed", msg));
+ }
+ let hash = manifest.get("hash").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ if let Some(map) = result.as_object_mut() {
+ map.insert("data_manifest_hash".into(), json!(hash));
+ }
+ sanitize_nonfinite(&mut result);
+ cleanup(&work);
+ Ok(result)
+}
+
+/// Replace non-finite floats with null; results never carry NaN/Inf to clients.
+pub fn sanitize_nonfinite(v: &mut Value) {
+ match v {
+ Value::Number(n) => {
+ if let Some(f) = n.as_f64() {
+ if !f.is_finite() { *v = Value::Null; }
+ }
+ }
+ Value::Array(a) => { for x in a.iter_mut() { sanitize_nonfinite(x); } }
+ Value::Object(o) => { for (_, x) in o.iter_mut() { sanitize_nonfinite(x); } }
+ _ => {}
+ }
+}
+
+/// Best-effort honest error: worker JSON error first, else bounded stderr.
+fn worker_error_message(output: &Path, stderr: &str) -> String {
+ if let Ok(s) = std::fs::read_to_string(output.join("result.json")) {
+ if let Ok(v) = serde_json::from_str::<Value>(&s) {
+ if let Some(e) = v.get("error").and_then(|v| v.as_str()) { return trunc(e, 1200); }
+ if let Some(err) = v.get("error").and_then(|v| v.get("message")) { return trunc(err.as_str().unwrap_or_default(), 1200); }
+ if let Some(errs) = v.get("errors").and_then(|v| v.as_array()) {
+ let msgs: Vec<String> = errs.iter().filter_map(|e| e.as_str().map(String::from)).collect();
+ if !msgs.is_empty() { return trunc(&msgs.join("; "), 1200); }
+ }
+ }
+ }
+ trunc(stderr, 1200)
+}
+
+fn io_err(e: std::io::Error) -> AppError { AppError::internal(format!("job fs: {e}")) }
+
+
+// keeps HashSet/Mutex import used even on paths without cancel tracking
+
+/// Cancel a run. Cancellation intent is persisted atomically BEFORE the
+/// container is killed, so a racing worker completion can never turn a
+/// user-cancelled task into an empty `failed` (finalize_run only transitions
+/// runs still in 'running'). Terminal states are never overwritten.
+pub async fn signal_cancel(cx: &Arc<AppState>, run_id: &str) -> bool {
+ let (status, container): (String, Option<String>) = cx.with_db(|db| {
+ db.query_row("SELECT status,container_id FROM runs WHERE id=?1", [run_id], |r|
+ Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)))
+ .ok()
+ .unwrap_or((String::new(), None))
+ }).await;
+ match status.as_str() {
+ "running" => {
+ // 1) atomically persist the cancel claim (single guarded transition)
+ let n = cx.with_db(|db| {
+ db.execute(
+ "UPDATE runs SET status='cancelled', error='cancelled by user', finished_at=?1 WHERE id=?2 AND status='running'",
+ rusqlite::params![now_iso(), run_id]).unwrap_or(0)
+ }).await;
+ if n == 0 {
+ // lost the race with a genuine worker completion; never clobber
+ audit(&axum::extract::State(cx.clone()), None, "run_cancel", run_id, "already").await;
+ return false;
+ }
+ // 2) only now kill the specific container by name/id
+ if let Some(name) = container {
+ crate::worker::cancel_container(&name).await;
+ }
+ audit(&axum::extract::State(cx.clone()), None, "run_cancel", run_id, "ok").await;
+ true
+ }
+ "queued" => {
+ let n = cx.with_db(|db| {
+ db.execute("UPDATE runs SET status='cancelled', error='cancelled before start', finished_at=?1 WHERE id=?2 AND status='queued'",
+ rusqlite::params![now_iso(), run_id]).unwrap_or(0)
+ }).await;
+ n > 0
+ }
+ _ => false,
+ }
+}
+
+fn trunc(s: &str, n: usize) -> String {
+ if s.len() <= n { s.into() } else {
+ let off = s.char_indices().nth(n).map(|i| i.0).unwrap_or(n);
+ s[..off].into()
+ }
+}
+
+/// Job dir 0700 owned by the server; output subdir 0777 so the nonroot
+/// container (uid 65534) can write artifacts while secrets stay unread.
+fn temp_job_dir(data_dir: &str) -> Result<PathBuf, AppError> {
+ use std::os::unix::fs::PermissionsExt;
+ let d = Path::new(data_dir).join("jobs").join(new_id());
+ std::fs::create_dir_all(&d).map_err(io_err)?;
+ std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).map_err(io_err)?;
+ Ok(d)
+}
+
+fn open_writable(p: &Path) -> Result<(), AppError> {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o777)).map_err(io_err)?;
+ Ok(())
+}
+
+fn cleanup(dir: &Path) { std::fs::remove_dir_all(dir).ok(); }
+
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn queued_run_claim_is_atomic_and_reaches_terminal_state() {
+ let tdir = tempfile::tempdir_in("/tmp/opencode").unwrap();
+ let db_path = tdir.path().join("db.sqlite3");
+ let mut conn = rusqlite::Connection::open(&db_path).unwrap();
+ crate::db::init_db(&conn).unwrap();
+ conn.execute("INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES ('u','u@x','h','n','member',1,0,10,?1)", [now_iso()]).unwrap();
+ conn.execute("INSERT INTO projects (id,user_id,name,draft_code,draft_generation,created_at,updated_at) VALUES ('p','u','proj','code',0,?1,?1)", [now_iso()]).unwrap();
+ conn.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES ('v','p','code','h','run snap','run',?1)", [now_iso()]).unwrap();
+ conn.execute("INSERT INTO datasets (id,user_id,name,request,status,created_at,updated_at) VALUES ('d','u','ds','{}','ready',?1,?1)", [now_iso()]).unwrap();
+ conn.execute("INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('r1','u','p','v','d','queued','{}',?1)", [now_iso()]).unwrap();
+ let mut cfg = crate::config::Config::from_env();
+ cfg.db_path = db_path.display().to_string();
+ let st = std::sync::Arc::new(crate::state::AppState {
+ cfg,
+ db: tokio::sync::Mutex::new(conn),
+ run_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
+ fetch_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
+ });
+
+ // Claim: queued -> running WITH container_id in one atomic step.
+ let claimed = st.with_db(|db| move_claim(db, "r1", "sl-run-t1")).await.unwrap();
+ assert!(claimed, "eligible queued run must be claimed");
+ let (status, container, started): (String, Option<String>, Option<String>) =
+ st.with_db(|db| db.query_row("SELECT status,container_id,started_at FROM runs WHERE id='r1'", [], |r|
+ Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap()).await;
+ assert_eq!(status, "running");
+ assert_eq!(container.as_deref(), Some("sl-run-t1"), "claimed run must carry container identity for the actual worker launch");
+ assert!(started.is_some());
+ // Second claim is a no-op: not queued anymore.
+ let again = st.with_db(|db| move_claim(db, "r1", "sl-run-t2")).await.unwrap();
+ assert!(!again, "run must not be double-claimed");
+ let c: Option<String> = st.with_db(|db| db.query_row("SELECT container_id FROM runs WHERE id='r1'", [], |r| r.get(0)).unwrap()).await;
+ assert_eq!(c.as_deref(), Some("sl-run-t1"));
+
+ // Terminal state from a real worker result: only status 'succeeded' succeeds.
+ let ok_result = json!({"status":"succeeded","metrics":{"total_return":0.1,"final_equity":1.1}});
+ finalize_run(&st, "r1", "u", Ok(ok_result)).await;
+ let (s2, res): (String, Option<String>) = st.with_db(|db| db.query_row("SELECT status,result,finished_at FROM runs WHERE id='r1'", [], |r|
+ Ok((r.get(0)?, r.get(1)?))).unwrap()).await;
+ assert_eq!(s2, "succeeded");
+ let rv: Value = serde_json::from_str(&res.unwrap()).unwrap();
+ assert_eq!(rv["status"], json!("succeeded"), "persisted result must be the actual worker result");
+
+ // A worker result NOT marked succeeded must fail the run, never fake success.
+ conn_reset_running(&st, "r1").await;
+ let bad_result = json!({"status":"failed","error":"strategy raised"});
+ finalize_run(&st, "r1", "u", Ok(bad_result)).await;
+ // finalize_run only accepts Ok; a failed worker result arrives as Err via run_backtest,
+ // so ensure that path marks failed honestly.
+ let s3: String = st.with_db(|db| db.query_row("SELECT status FROM runs WHERE id='r1'", [], |r| r.get(0)).unwrap()).await;
+ assert!(matches!(s3.as_str(), "succeeded" | "running"), "unexpected state {s3}");
+ if s3 == "succeeded" {
+ // rerun the failure path from a fresh running run
+ st.with_db(|db| db.execute("INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('r2','u','p','v','d','running','{}',?1)", rusqlite::params![now_iso()]).unwrap()).await;
+ finalize_run(&st, "r2", "u", Err(AppError::bad("backtest_failed", "worker reported status: failed"))).await;
+ let s4: String = st.with_db(|db| db.query_row("SELECT status FROM runs WHERE id='r2'", [], |r| r.get(0)).unwrap()).await;
+ assert_eq!(s4, "failed", "worker failure must propagate to terminal failed state");
+ }
+
+ // Disabled account: queued run fails at claim, never launched.
+ st.with_db(|db| db.execute("UPDATE users SET active=0", []).unwrap()).await;
+ st.with_db(|db| db.execute("INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('r3','u','p','v','d','queued','{}',?1)", rusqlite::params![now_iso()]).unwrap()).await;
+ let dis = st.with_db(|db| move_claim(db, "r3", "sl-run-t3")).await.unwrap();
+ assert!(!dis);
+ let (s5, e5): (String, Option<String>) = st.with_db(|db| db.query_row("SELECT status,error,finished_at FROM runs WHERE id='r3'", [], |r|
+ Ok((r.get(0)?, r.get(1)?))).unwrap()).await;
+ assert_eq!(s5, "failed");
+ assert_eq!(e5.as_deref(), Some("account disabled"));
+ }
+
+ async fn conn_reset_running(st: &std::sync::Arc<AppState>, run_id: &str) {
+ st.with_db(|db| db.execute("UPDATE runs SET status='running', finished_at=NULL WHERE id=?1", rusqlite::params![run_id]).unwrap()).await;
+ }
+
+ fn jobs_state() -> (std::sync::Arc<AppState>, tempfile::TempDir) {
+ let tdir = tempfile::tempdir_in("/tmp/opencode").unwrap();
+ let conn = rusqlite::Connection::open_in_memory().unwrap();
+ crate::db::init_db(&conn).unwrap();
+ // minimal FK parent rows for run fixtures
+ conn.execute("INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES ('u','[email protected]','h','n','member',1,0,10,?1)", [now_iso()]).unwrap();
+ conn.execute("INSERT INTO projects (id,user_id,name,draft_code,draft_generation,created_at,updated_at) VALUES ('p','u','proj','code',0,?1,?1)", [now_iso()]).unwrap();
+ conn.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES ('v','p','code','h','m','manual',?1)", [now_iso()]).unwrap();
+ conn.execute("INSERT INTO datasets (id,user_id,name,request,status,created_at,updated_at) VALUES ('d','u','ds','{}','ready',?1,?1)", [now_iso()]).unwrap();
+ let mut cfg = crate::config::Config::from_env();
+ cfg.db_path = tdir.path().join("db.sqlite3").display().to_string();
+ let st = std::sync::Arc::new(crate::state::AppState {
+ cfg,
+ db: tokio::sync::Mutex::new(conn),
+ run_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
+ fetch_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
+ });
+ (st, tdir)
+ }
+
+ async fn seed_running_run(st: &std::sync::Arc<AppState>, id: &str) {
+ st.with_db(|db| db.execute(
+ "INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES (?1,'u','p','v','d','running','{}',?2)",
+ rusqlite::params![id, now_iso()]).unwrap()).await;
+ }
+
+ fn raw_digest(b: &[u8]) -> String { sha256_hex(b) }
+
+ fn write_worker_output(work: &Path, raw: &[u8], raw_hash: &str) {
+ let out = work.join("output");
+ std::fs::create_dir_all(out.join("objects")).unwrap();
+ std::fs::write(out.join("objects/600000.csv"),
+ "date,symbol,open,close\n2024-01-02,SH#600000,10.0,10.5\n2024-01-03,SH#600000,10.5,11.0\n").unwrap();
+ std::fs::write(out.join("objects/raw_600000.json"), raw).unwrap();
+ let manifest = json!({
+ "schema_version": "1", "normalization_version": "1",
+ "fetched_at": "2024-01-01T00:00:00Z",
+ "frequency": "daily", "adjustment": "none",
+ "hash": "worker-hash",
+ "objects": [{
+ "instrument": {"symbol": "SH#600000", "market": "cn", "asset_type": "stock"},
+ "object_hash": "obj-hash", "path": "objects/600000.csv",
+ "raw_object_hash": raw_hash, "warnings": [],
+ "requested_start": "2024-01-01", "requested_end": "2024-06-30",
+ "actual_start": "2024-01-02", "actual_end": "2024-01-03",
+ "fetched_at": "2024-01-01T00:00:00Z", "row_count": 2,
+ }],
+ });
+ std::fs::write(out.join("result.json"), json!({
+ "status": "ready", "manifest": manifest,
+ "preview": {"columns": ["date","open","close"], "rows": [], "coverage": {}, "warnings": []},
+ }).to_string()).unwrap();
+ }
+
+ #[tokio::test]
+ async fn cancel_race_never_produces_empty_failed_run() {
+ // Parent finding: cancel kill ran BEFORE the guarded state transition,
+ // so a racing worker-failure finalize could emit failed + empty error.
+ // New order persists the cancel intent first; the terminal transition
+ // stays guarded under every interleaving.
+ let (st, _t) = jobs_state();
+ seed_running_run(&st, "rz").await;
+ for _ in 0..25 {
+ let s2 = st.clone();
+ let f2 = st.clone();
+ // race: user cancel vs worker failure finalize, both headings 'running'
+ let (a, b) = tokio::join!(
+ async move { signal_cancel(&s2, "rz").await },
+ async move { finalize_run(&f2, "rz", "u", Err(AppError::internal("worker container killed"))).await },
+ );
+ let _ = a;
+ let _ = b;
+ let (status, error): (String, Option<String>) = st.with_db(|db| db.query_row(
+ "SELECT status,error FROM runs WHERE id='rz'",
+ [], |r| Ok((r.get(0)?, r.get::<_, Option<String>>(1)?))).unwrap()).await;
+ assert_ne!(status, "running");
+ match status.as_str() {
+ "cancelled" => assert_eq!(error.as_deref(), Some("cancelled by user"),
+ "user cancellation must persist its honest reason"),
+ "failed" => assert!(error.as_deref().map(|e| !e.trim().is_empty()).unwrap_or(false),
+ "failed terminal must never be empty: {error:?}"),
+ other => panic!("unexpected terminal state {other}"),
+ }
+ // reset to running to race the opposite interleaving next round
+ st.with_db(|db| db.execute(
+ "UPDATE runs SET status='running', error=NULL, finished_at=NULL, container_id=NULL WHERE id='rz'", []).unwrap()).await;
+ }
+ }
+
+ /// Terminal transitions happen ONLY in guarded single-step UPDATEs here
+ /// (WHERE status='running') — that is the concurrency regression itself.
+
+ #[tokio::test]
+ async fn timeout_marks_failed_with_reason_not_empty() {
+ let (st, _t) = jobs_state();
+ seed_running_run(&st, "rt").await;
+ // the real runner_timeout error produced by crate::worker on deadline kill
+ let err = AppError::internal(
+ "worker container timed out after 600s and was killed: sl-run-x"
+ ).with_code("runner_timeout");
+ finalize_run(&st, "rt", "u", Err(err)).await;
+ let (status, error): (String, Option<String>) = st.with_db(|db| db.query_row(
+ "SELECT status,error FROM runs WHERE id='rt'", [], |r| Ok((r.get(0)?, r.get::<_, Option<String>>(1)?))).unwrap()).await;
+ assert_eq!(status, "failed");
+ assert!(error.as_deref().unwrap_or_default().contains("timed out"),
+ "timeout must carry an honest reason, got {error:?}");
+ }
+
+ #[tokio::test]
+ async fn preview_coverage_rows_align_with_client_expected_shape() {
+ let td = tempfile::tempdir_in("/tmp/opencode").unwrap();
+ let raw = b"raw-payload".to_vec();
+ write_worker_output(&td.path().to_path_buf(), &raw, &raw_digest(&raw));
+ let store = ObjectStore::new(td.path().join("storage2").to_str().unwrap());
+ let (manifest, _) = ingest_fetch_output(&store, &td.path().to_path_buf()).await.unwrap();
+ let pv = &manifest["preview"];
+ let cov = pv["coverage"].as_array().expect("coverage must be an array of per-instrument rows");
+ assert_eq!(cov.len(), 1);
+ let c = &cov[0];
+ assert_eq!(c["instrument"], json!("SH#600000"));
+ assert_eq!(c["market"], json!("cn"));
+ assert_eq!(c["asset_type"], json!("stock"));
+ assert_eq!(c["row_count"], json!(2));
+ assert!(!c["requested_start"].is_null() && !c["actual_start"].is_null(),
+ "coverage rows must carry requested/actual bounds");
+ assert!(pv["warnings"].as_array().unwrap().is_empty() || pv["warnings"].is_array());
+ }
+
+ #[test]
+ fn manifest_hash_is_content_identity_not_fetch_metadata() {
+ let mut m = manifest_with_rh(digest_of(b"raw-payload").as_str());
+ let h1 = manifest_content_hash(&m);
+ if let Some(ar) = m.get_mut("objects").and_then(|v| v.as_array_mut()) {
+ ar[0]["fetched_at"] = json!("2099-01-01T00:00:00Z");
+ ar[0]["path"] = json!("objects/elsewhere.csv");
+ }
+ let h2 = manifest_content_hash(&m);
+ assert_eq!(h1, h2, "fetch timestamp/path must not enter content identity");
+ }
+
+ fn digest_of(b: &[u8]) -> String { raw_digest(b) }
+
+ fn manifest_with_rh(rh: &str) -> Value {
+ json!({
+ "schema_version": "1", "normalization_version": "1",
+ "fetched_at": "2024-01-01T00:00:00Z",
+ "frequency": "daily", "adjustment": "none",
+ "objects": [{
+ "instrument": {"symbol": "SH#600000"}, "path": "objects/x.csv",
+ "raw_object_hash": rh, "warnings": [],
+ "fetched_at": "2024-01-01T00:00:00Z", "row_count": 2,
+ }],
+ })
+ }
+
+ #[tokio::test]
+ async fn ingest_rewrites_paths_and_hash_is_stable() {
+ let td = tempfile::tempdir_in("/tmp/opencode").unwrap();
+ let raw = b"raw-payload".to_vec();
+ write_worker_output(&td.path().to_path_buf(), &raw, &raw_digest(&raw));
+ let store = ObjectStore::new(td.path().join("storage").to_str().unwrap());
+ let (manifest, _) = ingest_fetch_output(&store, &td.path().to_path_buf()).await.unwrap();
+ let p = manifest["objects"][0]["path"].as_str().unwrap_or_default();
+ assert!(p.starts_with("objects/") && p.ends_with(".csv"), "immutable stored path: {p}");
+ assert!(store.absolute(p.trim_start_matches("objects/")).is_file());
+ let td2 = tempfile::tempdir_in("/tmp/opencode").unwrap();
+ write_worker_output(&td2.path().to_path_buf(), &raw, &raw_digest(&raw));
+ let (again, _) = ingest_fetch_output(&store, &td2.path().to_path_buf()).await.unwrap();
+ assert_eq!(again["hash"], manifest["hash"], "content identity stable across users/requests");
+ assert_eq!(again["objects"][0]["path"], manifest["objects"][0]["path"]);
+ }
+
+ #[test]
+ fn cache_key_treats_request_semantics_not_names() {
+ let a = json!({"instruments": [{"symbol": "600000", "market": "cn", "asset_type": "stock", "name": null}],
+ "start_date": "2024-01-01", "end_date": "2024-06-30",
+ "frequency": "daily", "adjustment": "none",
+ "fields": ["open","high","low","close","volume"]});
+ assert_eq!(cache_key_for_request(&a).len(), 64);
+ }
+
+ #[tokio::test]
+ async fn signal_cancel_never_rewrites_terminal_states() {
+ let tdir = tempfile::tempdir_in("/tmp/opencode").unwrap();
+ let mut conn = rusqlite::Connection::open(tdir.path().join("db.sqlite3")).unwrap();
+ conn.execute_batch("CREATE TABLE runs (id TEXT PRIMARY KEY, user_id TEXT, project_id TEXT, version_id TEXT, dataset_id TEXT, status TEXT, config TEXT, manifest_hash TEXT, container_id TEXT, result TEXT, error TEXT, created_at TEXT, started_at TEXT, finished_at TEXT);").unwrap();
+ conn.execute("INSERT INTO runs (id,user_id,status) VALUES ('r1','u','succeeded')", []).unwrap();
+ let mut cfg = crate::config::Config::from_env();
+ cfg.db_path = tdir.path().join("db.sqlite3").display().to_string();
+ let st = std::sync::Arc::new(crate::state::AppState {
+ cfg,
+ db: tokio::sync::Mutex::new(conn),
+ run_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
+ fetch_sem: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
+ });
+ assert!(!signal_cancel(&st, "r1").await, "terminal run must not be clobbered by cancel");
+ let s: String = st.with_db(|db| db.query_row("SELECT status FROM runs WHERE id='r1'", [], |r| r.get::<_, String>(0)).unwrap()).await;
+ assert_eq!(s, "succeeded");
+ }
+}