mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 23:44:16 +08:00
* refactor: 「群机器人 Webhook」统一更名为「群推送 Webhook」 "群机器人"易与后续接入的"智能机器人(API 模式)"混淆。 该通道本质是单向往群聊推送消息, 更名为「群推送 Webhook」更准确。 涉及: 飞书/企业微信的 UI 标签、操作指引、后端文档字符串、 错误提示文案(代码逻辑/接口不变)。覆盖 6 个文件, 纯文案改动。 * feat: 数据修正功能 + 盘后管道暂停实时行情防竞态 数据修正/补数据: - 数据页顶部新增「修正数据」按钮, 弹窗选起始日期重拉到今天 - 复用盘后管道全流程 (维表/A股日K/除权/enriched/指数), 仅日期由用户传入 - run_now() 加 override_start_date 参数, 注入 A股日K + 指数拉取起点 - 新增 /api/kline/repair_daily 端点 (异步 job + 进度轮询) - 前端 RepairDailyPanel + DatePicker, 默认起始日期为30天前 实时行情暂停机制 (防写盘竞态): - QuoteService 加 _paused 标志 + pause()/resume()/paused() 上下文管理器 - 盘后管道/数据修正运行期间自动暂停实时行情取数, 防止覆写同一批 parquet - toggle 端点: 暂停态下禁止开启实时行情 (409) - 前端开关: 暂停时 disabled + 显示「数据同步运行中,已临时暂停」 - 三处注入 pause: pipeline.py / kline.py(repair_daily) / daily_pipeline.py(定时)
107 lines
4.2 KiB
Python
107 lines
4.2 KiB
Python
"""盘后管道 API — 异步触发 + 进度跟踪。"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import concurrent.futures as _cf
|
||
import logging
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
|
||
from app.jobs import daily_pipeline
|
||
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
|
||
from app.api.data import invalidate_storage_cache
|
||
|
||
# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
|
||
_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task")
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/pipeline", tags=["pipeline"])
|
||
|
||
|
||
@router.post("/run")
|
||
async def run_now(request: Request) -> dict:
|
||
"""异步触发盘后管道,立即返回 job_id。客户端轮询 /jobs/{id} 拿进度。
|
||
|
||
若已有任务在跑,**返回该任务 id 而不是开新任务**(防止并发拉数据撞限流)。
|
||
但如果该任务已运行超过 10 分钟 (可能因 reload 卡死), 强制标记为失败后重新创建。
|
||
"""
|
||
repo = request.app.state.repo
|
||
capset = request.app.state.capabilities
|
||
|
||
# 检测卡死的 running job (如 reload 后孤儿 task / 网络读无限阻塞)。
|
||
# reap_stale 会在 /run 和 /jobs/{id} 轮询端点都调用,保证卡死后能自愈。
|
||
job_store.reap_stale()
|
||
|
||
# 单飞: 复用任何活跃 (pending∨running) 任务, is_new=False 时不再调度新任务
|
||
job_id, is_new = job_store.create()
|
||
if not is_new:
|
||
return {"job_id": job_id, "reused": True}
|
||
|
||
# 在 executor 里跑同步任务(pipeline 内部都是阻塞 IO + CPU)
|
||
async def task() -> None:
|
||
# 重任务执行槽: 防僵尸并发(reap 后线程仍活时新任务不得并行写 parquet)
|
||
if not try_acquire_run_slot():
|
||
job_store.fail(job_id, "已有数据任务在运行(或上一次任务卡死未结束),请稍后再试")
|
||
return
|
||
# 管道运行期间暂停实时行情取数, 防止覆写同一批 parquet 竞态
|
||
qs = getattr(request.app.state, "quote_service", None)
|
||
try:
|
||
job_store.start(job_id)
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def progress(stage: str, pct: int, msg: str, stage_pct: int | None = None,
|
||
skip_log: bool = False) -> None:
|
||
job_store.progress(job_id, stage, pct, msg, stage_pct=stage_pct, skip_log=skip_log)
|
||
|
||
def _run() -> dict:
|
||
if qs:
|
||
with qs.paused():
|
||
return daily_pipeline.run_now(repo, capset, on_progress=progress)
|
||
return daily_pipeline.run_now(repo, capset, on_progress=progress)
|
||
|
||
result = await loop.run_in_executor(_long_task_executor, _run)
|
||
job_store.succeed(job_id, result)
|
||
invalidate_storage_cache()
|
||
repo.refresh_cache() # 刷新 Polars 缓存
|
||
except Exception as e: # noqa: BLE001
|
||
logger.exception("pipeline failed")
|
||
job_store.fail(job_id, str(e))
|
||
invalidate_storage_cache()
|
||
finally:
|
||
release_run_slot()
|
||
|
||
asyncio.create_task(task())
|
||
return {"job_id": job_id, "reused": False}
|
||
|
||
|
||
@router.get("/jobs/{job_id}")
|
||
def get_job(job_id: str) -> dict:
|
||
# 每次轮询都检查卡死 job — 前端每秒轮询,STALE_JOB_TIMEOUT_S(10min)后必定自愈,
|
||
# 无需用户再次手动点「同步」。
|
||
job_store.reap_stale()
|
||
j = job_store.get(job_id)
|
||
if not j:
|
||
raise HTTPException(status_code=404, detail="job not found")
|
||
return j
|
||
|
||
|
||
@router.post("/jobs/{job_id}/cancel")
|
||
def cancel_job(job_id: str) -> dict:
|
||
"""手动取消一个 running 的 job。"""
|
||
j = job_store.get(job_id)
|
||
if not j:
|
||
raise HTTPException(status_code=404, detail="job not found")
|
||
if j["status"] not in ("running", "pending"):
|
||
raise HTTPException(status_code=400, detail=f"job status is {j['status']}, cannot cancel")
|
||
job_store.fail(job_id, "用户手动取消")
|
||
return {"cancelled": job_id}
|
||
|
||
|
||
@router.get("/jobs")
|
||
def list_jobs(limit: int = 20) -> dict:
|
||
return {
|
||
"active_id": job_store.active_id(),
|
||
"jobs": job_store.list_recent(limit=limit),
|
||
}
|