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:
+27
-2
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user