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)