feat(job): D1 Job 阶段上报(v3 §23)+ 列表/取消端点

- ResearchService.run_backtest/run_factor_test 支持 on_stage 回调(data_loading →
  factor_calculation/backtesting → analysis);job_executor 经独立短会话把 stage 写库
  (子进程模式同样走 DB),收尾回读最后阶段避免覆盖
- /api/jobs:GET 列表(kind/limit)、POST /{id}/cancel(queued/running → CANCELLED +
  终止子进程 terminate_active;父进程兜底已跳过 CANCELLED)
- SSE /jobs/{id}/events 现会携带 stage
- tests/test_job_stages.py:成功 job 终态 stage=analysis;executor 取消 queued/幂等/
  已完成不可取消;API 列表+终态不可取消+404;全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 07:37:25 +08:00
parent 67d3aa1349
commit 7c268e43df
4 changed files with 271 additions and 8 deletions
+27 -2
View File
@@ -13,12 +13,13 @@ from __future__ import annotations
import asyncio
import json
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.api.deps import DbSession, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background
from app.application.services.job_executor import new_id, run_job_background, terminate_active
from app.domain.entities.research import (
BacktestResult,
FactorTestReport,
@@ -69,6 +70,30 @@ def create_job(
return {"job_id": job.id, "status": job.status}
@router.get("", summary="Job 列表")
def list_jobs(
job_repo: JobRepoDep,
kind: Annotated[str | None, Query(description="按类型过滤(backtest/factor_test)")] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 20,
) -> list[dict]:
return [_job_view(j) for j in job_repo.list_recent(kind=kind, limit=limit)]
@router.post("/{job_id}/cancel", summary="取消 Job(queued/running)")
def cancel_job(job_id: str, session: DbSession, job_repo: JobRepoDep) -> dict:
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} 不存在")
if job.status not in (JobStatus.QUEUED, JobStatus.RUNNING):
return {"job_id": job_id, "status": job.status, "cancelled": False}
job.status = JobStatus.CANCELLED
job.stage = None
job_repo.update(job)
session.commit()
terminate_active(job_id) # 终止研究子进程(若有);父进程兜底已跳过 CANCELLED
return {"job_id": job_id, "status": JobStatus.CANCELLED, "cancelled": True}
@router.get("/{job_id}", summary="查询 Job 状态与结果")
def get_job(job_id: str, job_repo: JobRepoDep) -> dict:
job = job_repo.get(job_id)
@@ -95,10 +95,23 @@ def _execute_inner(
service = ResearchService(
stock_repo_factory(session), daily_repo_factory(session), engine
)
def _set_stage(name: str) -> None:
"""阶段上报(v3 §23):独立短会话写 job.stage 并 commit(子进程同样走 DB)。"""
try:
with session_factory() as st_sess:
st = job_repo_factory(st_sess).get(job_id)
if st is not None and st.status == JobStatus.RUNNING:
st.stage = name
job_repo_factory(st_sess).update(st)
st_sess.commit()
except Exception: # noqa: BLE001 —— 阶段上报失败不阻断执行
pass
if spec.type == "backtest":
result = service.run_backtest(spec)
result = service.run_backtest(spec, on_stage=_set_stage)
else:
result = service.run_factor_test(spec)
result = service.run_factor_test(spec, on_stage=_set_stage)
result_json = json.dumps(result.model_dump(mode="json"), ensure_ascii=False)
experiment = ExperimentRecord(
@@ -122,6 +135,14 @@ def _execute_inner(
job.error = f"{type(exc).__name__}: {exc}"
finally:
job.finished_at = datetime.now()
# 回读最后一次阶段上报(_set_stage 经独立会话写库),避免被本会话覆盖
try:
with session_factory() as last_sess:
last = job_repo_factory(last_sess).get(job_id)
if last is not None:
job.stage = last.stage
except Exception: # noqa: BLE001
pass
job_repo.update(job)
session.commit()
@@ -353,6 +374,42 @@ def _run_in_subprocess(
)
def terminate_active(job_id: str) -> bool:
"""终止该 Job 的活动子进程(如有)。返回是否找到并终止。"""
with _active_lock:
proc = _active_jobs.get(job_id)
if proc is None:
return False
import contextlib
with contextlib.suppress(Exception):
proc.terminate()
return True
def cancel_job(
job_id: str,
*,
session_factory: Callable | None = None,
job_repo_factory: Callable | None = None,
) -> bool:
"""取消 queued/running Job(置 CANCELLED 并终止子进程);不可取消返回 False。"""
facts = default_factories()
sf = session_factory or facts["session_factory"]
jr = job_repo_factory or facts["job_repo_factory"]
with sf() as session:
repo = jr(session)
job = repo.get(job_id)
if job is None or job.status not in (JobStatus.QUEUED, JobStatus.RUNNING):
return False
job.status = JobStatus.CANCELLED
job.stage = None
repo.update(job)
session.commit()
terminate_active(job_id)
return True
def mark_stale_jobs_failed(
*,
session_factory: Callable | None = None,
+21 -4
View File
@@ -120,17 +120,29 @@ class ResearchService:
self._engine = engine
self._index_repo = index_repo
def run_factor_test(self, spec: ResearchSpec, horizon_days: int = 21) -> FactorTestReport:
def run_factor_test(
self, spec: ResearchSpec, horizon_days: int = 21, on_stage=None
) -> FactorTestReport:
"""on_stage(str):执行阶段回调(data_loading / factor_calculation / analysis),
供 Job 状态机上报 stage(v3 §23)。"""
if spec.type != "factor_test":
raise ValueError("factor_test 用例需要 spec.type=factor_test")
_stage(on_stage, "data_loading")
daily = self._load_daily(spec)
return self._engine.run_factor_test(daily, spec, horizon_days=horizon_days)
_stage(on_stage, "factor_calculation")
report = self._engine.run_factor_test(daily, spec, horizon_days=horizon_days)
_stage(on_stage, "analysis")
return report
def run_backtest(self, spec: ResearchSpec) -> BacktestResult:
def run_backtest(self, spec: ResearchSpec, on_stage=None) -> BacktestResult:
if spec.type != "backtest":
raise ValueError("backtest 用例需要 spec.type=backtest")
_stage(on_stage, "data_loading")
daily = self._load_daily(spec)
return self._engine.run_backtest(daily, spec)
_stage(on_stage, "backtesting")
result = self._engine.run_backtest(daily, spec)
_stage(on_stage, "analysis")
return result
def run_factor_correlation(self, spec: ResearchSpec) -> FactorCorrelationReport:
"""多因子两两相关(v3 §12):同 universe/period 装配 → 横截面相关矩阵。"""
@@ -158,3 +170,8 @@ class ResearchService:
sorted(required),
adjust=spec.price_adjustment,
)
def _stage(cb, name: str) -> None:
if cb is not None:
cb(name)