fix(sync): 修复同步卡死在26%及启动期视图注册崩溃 (#47) (#48)

三处根因:

1. 限流逻辑失效 (kline_sync.py ×3, index_sync.py ×2)
   条件 `len(chunks) > rpm` 在 free 档恒为 False (56 chunks < 60 rpm),
   导致节流 sleep 永不执行, 请求密集打出触发服务端限流 → 卡死。
   改为始终按 interval (60/rpm) 节流。

2. 卡死看门狗无法自愈 (pipeline_jobs.py, api/pipeline.py)
   原超时检查只在再次点「同步」时触发, 等待中永不回收。
   抽出 JobStore.reap_stale() (STALE_JOB_TIMEOUT_S=600), 在
   /run 和 /jobs/{id} 轮询端点都调用 — 前端每秒轮询, 10分钟后
   必定自动回收卡死 job, UI 不再永久停在 26%。

3. 启动期视图注册异常捕获不足 (repository.py)
   _register_views 只捕获 duckdb.IOException, 跨版本/平台空目录
   可能抛 CatalogException 等炸掉 lifespan。放宽到 Exception。

Co-authored-by: shy3130 <shy3130@users.noreply.github.com>
This commit is contained in:
wshy
2026-07-04 15:53:50 +08:00
committed by GitHub
co-authored by shy3130
parent 1fd7e84785
commit 57f417e6eb
5 changed files with 50 additions and 22 deletions
+6 -16
View File
@@ -29,22 +29,9 @@ async def run_now(request: Request) -> dict:
repo = request.app.state.repo
capset = request.app.state.capabilities
# 检测卡死的 running job (如 reload 后孤儿 task)
existing_id = job_store.active_id()
if existing_id:
existing = job_store.get(existing_id)
if existing and existing["status"] == "running":
from datetime import datetime, timezone
started = existing.get("started_at")
if started:
try:
start_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
elapsed = (datetime.now(timezone.utc) - start_dt).total_seconds()
if elapsed > 600: # 超过 10 分钟视为卡死
logger.warning("强制取消卡死 job %s (已运行 %.0fs)", existing_id, elapsed)
job_store.fail(existing_id, "超时自动取消 (疑似 reload 后孤儿 task)")
except Exception:
pass
# 检测卡死的 running job (如 reload 后孤儿 task / 网络读无限阻塞)。
# reap_stale 会在 /run 和 /jobs/{id} 轮询端点都调用,保证卡死后能自愈。
job_store.reap_stale()
job_id = job_store.create()
@@ -81,6 +68,9 @@ async def run_now(request: Request) -> dict:
@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")
+2 -2
View File
@@ -233,7 +233,7 @@ def sync_and_persist_index_daily(
interval = (60.0 / rpm) if rpm else 0
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
for i, chunk in enumerate(chunks):
if i > 0 and interval > 0 and len(chunks) > rpm:
if i > 0 and interval > 0:
import time
time.sleep(interval)
raw = kline_sync.sync_daily_batch(
@@ -332,7 +332,7 @@ def sync_and_persist_etf_daily(
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
factors = _load_etf_factors(repo)
for i, chunk in enumerate(chunks):
if i > 0 and interval > 0 and len(chunks) > rpm:
if i > 0 and interval > 0:
import time
time.sleep(interval)
raw = kline_sync.sync_daily_batch(
+3 -3
View File
@@ -92,7 +92,7 @@ def sync_daily_batch(symbols: list[str],
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
for i, chunk in enumerate(chunks):
if i > 0 and interval > 0 and len(chunks) > rpm:
if i > 0 and interval > 0:
time.sleep(interval)
try:
if start_time and end_time:
@@ -295,7 +295,7 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
all_dfs: list[pl.DataFrame] = []
for i, chunk in enumerate(chunks):
if i > 0 and interval > 0 and len(chunks) > rpm:
if i > 0 and interval > 0:
time.sleep(interval)
try:
raw = tf.klines.ex_factors(chunk, **sdk_kwargs)
@@ -427,7 +427,7 @@ def sync_minute_batch(
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
for i, chunk in enumerate(chunks):
if i > 0 and interval > 0 and len(chunks) > rpm:
if i > 0 and interval > 0:
time.sleep(interval)
try:
if start_time and end_time:
+35
View File
@@ -23,6 +23,11 @@ logger = logging.getLogger(__name__)
JobStatus = Literal["pending", "running", "succeeded", "failed"]
# 运行超过此秒数视为卡死(reload 后孤儿 task / 网络读无限阻塞等)。
# 由 reap_stale() 在 /run 和 /jobs/{id} 轮询端点检查 — 保证卡死后能自愈,
# 无需用户再次点击「同步」。
STALE_JOB_TIMEOUT_S = 600
def _default_store_dir() -> Path:
from app.config import settings
@@ -208,6 +213,36 @@ class JobStore:
def active_id(self) -> str | None:
return self._active_id
def reap_stale(self, timeout_s: int = STALE_JOB_TIMEOUT_S) -> None:
"""回收运行超过 timeout_s 的卡死 running job(标记为 failed)。
在 /run 和 /jobs/{id} 轮询端点都会调用 — 保证卡死后任意轮询都能自愈,
无需用户再次手动触发同步。reload 后的孤儿 task(内存里已无 job 记录)
不在此处理:它们没有 active_id,只能靠 executor 线程自然结束或进程重启。
"""
with self._lock:
jid = self._active_id
if not jid:
return
j = self._active_jobs.get(jid)
if not j or j.get("status") != "running":
return
started = j.get("started_at")
if not started:
return
# 时间计算放到锁外(避免 datetime 解析持锁)。
# started_at 形如 "2026-07-04T12:00:00Z"(start() 用 datetime.utcnow 存)。
# 两端都用 timezone-aware UTC 比较,避免 naive/aware 混用导致 TypeError。
try:
start_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
elapsed = (datetime.now(start_dt.tzinfo) - start_dt).total_seconds()
except Exception: # noqa: BLE001
return
if elapsed > timeout_s:
logger.warning("reap_stale: 强制取消卡死 job %s (已运行 %.0fs)",
jid, elapsed)
self.fail(jid, f"超时自动取消 (运行 {int(elapsed)}s, 疑似卡死)")
def clear(self) -> None:
"""清空所有任务(内存 + 磁盘文件)。"""
with self._lock:
+4 -1
View File
@@ -180,7 +180,10 @@ class DataStore:
for sql in statements:
try:
self.db.execute(sql)
except duckdb.IOException:
except Exception as e: # noqa: BLE001
# 空数据目录(首次启动)或权限问题时 DuckDB 会抛 IOException;
# 跨版本/平台也可能抛 CatalogException 等。空目录缺视图不影响启动
# (后续同步写入数据后会重新刷新视图),这里一律降级为 debug 日志。
logger.debug("view registration skipped (no parquet yet): %s", sql[:60])
self._register_unified_views()