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
+164
View File
@@ -0,0 +1,164 @@
"""D1 Job 阶段上报 / 取消 / 列表测试。"""
from __future__ import annotations
from datetime import date, datetime
import pytest
from app.api import deps
from app.application.services import job_executor as je
from app.domain.entities.research import JobRecord, JobStatus, ResearchSpec
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyExperimentRepository,
SqlAlchemyJobRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyDailyBarRepository,
SqlAlchemyStockRepository,
)
from app.main import app
from app.quant.engine import LocalEngine
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from conftest_quant import bars_dataframe_to_daily_bars, synthetic_daily
_SYMS = ["60000" + str(i) + ".SH" for i in range(5)]
def _spec(**kw) -> ResearchSpec:
base = dict(
type="backtest",
universe={"exclude_st": False, "min_listing_days": 0, "symbols": _SYMS},
factors=[{"name": "momentum_60", "weight": 1.0}],
selection={"top_n": 2},
rebalance="monthly",
period=[date(2024, 5, 1), date(2024, 8, 31)],
)
base.update(kw)
return ResearchSpec(**base)
@pytest.fixture()
def sf(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'job.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
df = synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320)
with Session() as session:
SqlAlchemyStockRepository(session).upsert_many(
[
__import__("app.domain.entities.market", fromlist=["Stock"]).Stock(
symbol=s, name=f"测试{i}", list_date=date(1999, 1, 1)
)
for i, s in enumerate(_SYMS)
]
)
SqlAlchemyDailyBarRepository(session).upsert_many(bars_dataframe_to_daily_bars(df))
session.commit()
return Session
def _create(sf, job_id, spec=None):
with sf() as session:
SqlAlchemyJobRepository(session).create(
JobRecord(id=job_id, kind="backtest", spec_json=(spec or _spec()).model_dump_json(),
status=JobStatus.QUEUED, created_at=datetime.now())
)
session.commit()
class TestJobStages:
def test_stage_reported_and_success(self, sf) -> None:
job_id = "JOB-STAGE-1"
_create(sf, job_id)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyDailyBarRepository as D,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyStockRepository as S,
)
je.execute_job(
job_id,
session_factory=sf,
job_repo_factory=lambda s: SqlAlchemyJobRepository(s),
experiment_repo_factory=lambda s: SqlAlchemyExperimentRepository(s),
stock_repo_factory=lambda s: S(s),
daily_repo_factory=lambda s: D(s),
engine=LocalEngine(),
)
with sf() as session:
job = SqlAlchemyJobRepository(session).get(job_id)
assert job is not None and job.status == JobStatus.SUCCESS
# 阶段在成功时保留最后 analysis(v3 §23 阶段名)
assert job.stage == "analysis"
def test_cancel_queued_and_idempotent(self, sf) -> None:
job_id = "JOB-CANCEL-1"
_create(sf, job_id)
assert je.cancel_job(job_id, session_factory=sf,
job_repo_factory=lambda s: SqlAlchemyJobRepository(s)) is True
with sf() as session:
job = SqlAlchemyJobRepository(session).get(job_id)
assert job is not None and job.status == JobStatus.CANCELLED
# 已取消不可再次取消
assert je.cancel_job(job_id, session_factory=sf,
job_repo_factory=lambda s: SqlAlchemyJobRepository(s)) is False
def test_cancel_completed_false(self, sf) -> None:
job_id = "JOB-DONE"
_create(sf, job_id)
with sf() as session:
repo = SqlAlchemyJobRepository(session)
job = repo.get(job_id)
job.status = JobStatus.SUCCESS
repo.update(job)
session.commit()
assert je.cancel_job(job_id, session_factory=sf,
job_repo_factory=lambda s: SqlAlchemyJobRepository(s)) is False
@pytest.fixture()
def client(tmp_path, monkeypatch):
engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
# 后台执行(JOB_MODE=local)走全局 SessionLocal → 指向同一测试库
from app.infrastructure.persistence.sqlalchemy import session as sess_mod
monkeypatch.setattr(sess_mod, "SessionLocal", Session)
def _session_override():
with Session() as s:
yield s
app.dependency_overrides[deps.get_session] = _session_override
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
class TestJobsApi:
def test_list_and_cancel_finished(self, client) -> None:
# 提交一个会失败(未知因子)的 job:后台同步执行完 → failed
payload = _spec(factors=[{"name": "no_such", "weight": 1.0}]).model_dump(mode="json")
resp = client.post("/api/jobs", json=payload)
assert resp.status_code == 200
job_id = resp.json()["job_id"]
# 等待后台任务终态(TestClient 同步执行后台任务于响应后)
state = None
for _ in range(30):
state = client.get(f"/api/jobs/{job_id}").json()
if state["status"] in ("success", "failed", "cancelled"):
break
assert state["status"] == "failed" # 未知因子 → 失败
# 列表含该 job
rows = client.get("/api/jobs").json()
assert any(r["id"] == job_id for r in rows)
# 已完成(失败)不可取消
cancel = client.post(f"/api/jobs/{job_id}/cancel").json()
assert cancel["cancelled"] is False
assert client.post("/api/jobs/JOB-NOPE/cancel").status_code == 404