Files
tick-stock-panel/backend/app/services/ext_pull.py
T
shy3130 ab2b82da37 perf: 收窄缓存失效并优化异步任务与首屏加载
按 enriched 写入最早影响日期维护有界区间版本,保留全局发布一致性校验,使历史读取与回测缓存复用不受较晚行情更新影响;历史修订和未知范围变更仍失效。

将扩展数据解析、落盘与备份/策略导入移出事件循环;串行限制扩展数据写入,并在请求取消后保持执行中的写入限额及临时文件清理。

拆分 11 个扩展页面为轻量注册清单和按需页面,助手抽屉首次使用时加载;并发获取扩展后按固定顺序注册,为运行时脚本增加超时隔离。保持金融计算、业务数据格式与扩展 v1 契约不变。

验证:隔离全量后端 3923 项通过、7 项跳过、1 项预期失败;前端 447 项通过,TypeScript 与生产构建通过。Ruff 无新增告警,git diff --check 通过。浏览器验证 11 个扩展路由与脚本超时隔离。

性能:1000 标的、120 天、12 万行、30 次更新,历史面板读取中位数 7.19ms -> 0.14ms,计算 31 -> 1 次,结果一致。注入 250ms 解析耗时的心跳间隔中位最大值 269ms -> 11.3ms。启动必需 JS 2.33MB -> 0.65MB;10Mbps/40ms 网络与固定 API 响应下,登录页 3 次冷启动中位数 3.15s -> 1.12s。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-11-01 22:32:34 +08:00

885 lines
38 KiB
Python

"""扩展数据定时拉取引擎 — 从外部 API 拉取数据写入 Parquet。"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from contextlib import asynccontextmanager
from contextvars import ContextVar
from datetime import UTC, date, datetime, timedelta, timezone
from functools import reduce
from pathlib import Path
from typing import Any
import httpx
from app.market_time import CN_TZ, cn_now, cn_today
from app.services.ext_data import (
ExtConfig,
ExtConfigStore,
PullConfig,
rows_to_parquet,
run_ext_data_io,
)
logger = logging.getLogger(__name__)
_pull_client: ContextVar[httpx.AsyncClient | None] = ContextVar("ext_pull_client", default=None)
@asynccontextmanager
async def _pull_client_scope():
client = _pull_client.get()
if client is not None:
yield client
return
async with httpx.AsyncClient() as client:
token = _pull_client.set(client)
try:
yield client
finally:
_pull_client.reset(token)
def outbound_headers(user_headers: dict[str, str] | None = None) -> dict[str, str]:
"""扩展数据出站请求的默认标识头。
默认携带 User-Agent: tsp/<版本> 与 X-TSP-Client: tick-stock-panel,
供服务端 (如 tickflow-hub) 识别本项目的请求。用户在拉取配置里显式
设置的同名头优先 (大小写不敏感), 不被标识头覆盖。
"""
from app import __version__
defaults = {
"User-Agent": f"tsp/{__version__}",
"X-TSP-Client": "tick-stock-panel",
}
override = {k.lower() for k in (user_headers or {})}
return {
**{k: v for k, v in defaults.items() if k.lower() not in override},
**(user_headers or {}),
}
def _in_time_window(start: str | None, end: str | None) -> bool:
"""检查当前北京时间是否在每日时间窗口内。
start/end 为 "HH:MM" 格式。两者都为 None 时不限制(返回 True)。
支持跨午夜窗口(如 22:00-02:00)。
用北京时间而不是本地时间: 这个窗口是照着 A 股交易时段设的, 而
market_time 模块开篇就写明「服务器/容器本地时区不可靠 (python:slim
镜像默认 UTC)」。UTC 容器里 9:30-15:00 的窗口实际落在北京 17:30-23:00,
每天都在收盘之后。
"""
if not start or not end:
return True
now = cn_now().strftime("%H:%M")
if start <= end:
return start <= now < end
# 跨午夜: 如 22:00-02:00
return now >= start or now < end
# ---------------------------------------------------------------------------
# 响应解析
# ---------------------------------------------------------------------------
def _extract_rows(data: Any, path: str) -> list[dict]:
"""按 dot-path 从 JSON 响应中提取行数组。
例: path="data.list" → response["data"]["list"]
如果 path 为空,直接将 data 视为数组。
"""
if not path:
if isinstance(data, list):
return data
raise ValueError("response_path 为空但响应不是数组")
keys = path.split(".")
current = data
for key in keys:
if isinstance(current, dict):
if key not in current:
raise ValueError(f"响应中不存在路径 '{path}',缺失键 '{key}'")
current = current[key]
elif isinstance(current, list):
try:
current = current[int(key)]
except (ValueError, IndexError) as e:
raise ValueError(f"响应路径 '{path}' 解析失败: {e}") from e
else:
raise ValueError(f"响应路径 '{path}' 中间值不是 dict/list: {type(current)}")
if not isinstance(current, list):
raise ValueError(f"路径 '{path}' 指向的不是数组,而是 {type(current)}")
return current
def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]:
"""将外部字段名映射为内部配置字段名。field_map: {外部名: 内部名}。"""
if not field_map:
return rows
mapped = []
for row in rows:
new_row: dict = {}
for k, v in row.items():
mapped_key = field_map.get(k, k)
new_row[mapped_key] = v
mapped.append(new_row)
return mapped
# ---------------------------------------------------------------------------
# 拉取执行
# ---------------------------------------------------------------------------
def _format_date_value(day: date, date_format: str) -> str:
"""按配置格式把交易日序列化为接口参数值。
ts_s/ts_ms = 该交易日北京时间 00:00:00 的时间戳 (约定, 见 PULL_DATE_FORMATS);
未知格式回退 iso (PullConfig 构造时已归一, 这里再兜底)。
"""
if date_format == "compact":
return day.strftime("%Y%m%d")
if date_format == "ts_s":
return str(int(datetime(day.year, day.month, day.day, tzinfo=CN_TZ).timestamp()))
if date_format == "ts_ms":
return str(int(datetime(day.year, day.month, day.day, tzinfo=CN_TZ).timestamp() * 1000))
return day.isoformat()
def _with_date_param(url: str, date_param: str | None, day: date, date_format: str = "iso") -> str:
"""接口按日查询参数: ?{date_param}={按格式序列化的日期} (已有 query 用 &)。"""
if not date_param:
return url
sep = "&" if "?" in url else "?"
return f"{url}{sep}{date_param}={_format_date_value(day, date_format)}"
def since_cursor(data_dir, config: ExtConfig, day: date) -> str | None:
"""增量续传游标: 目标日本地分区 time_field 列的最大值 (字符串化)。
盘中多盘表 (配置 pull.since_param + time_field) 用它让源只返回新于本地的
盘次。缺口感知天然成立 —— 本地缺多少源补多少; 下列情况返回 None (不带
参数 = 整日全量), 全部是安全的回退方向:
- 未配置 since_param/time_field (PullConfig 构造时已归一联动)
- 本地无该日分区 (首拉/新交易日/分区被删修复)
- 分区缺 time_field 列 (schema 漂移) 或文件损坏、列为空
snapshot 模式无日期分区, 恒返回 None —— 增量仅对 timeseries 有意义。
"""
pull = config.pull
if not pull or not pull.since_param or not pull.time_field or pull.fanout:
return None # fanout 两级拉取 (目录→明细) 无整表游标语义
part = _day_partition(Path(data_dir), config.id, day)
if not part.exists():
return None
import polars as pl
try:
col = pl.read_parquet(part, columns=[pull.time_field])[pull.time_field]
value = col.max()
except Exception: # noqa: BLE001 — 列缺失/文件损坏 → 全量, 不阻断拉取
return None
if value is None:
return None
return str(value).strip() or None
# Hub 管理后台的会话 Cookie 名。它是有有效期的浏览器会话(默认 24h),
# 换机器/换部署即失效, 不能当作定时拉取的长期凭据。
_HUB_SESSION_COOKIES = frozenset({"tickflow_hub_access"})
def _hub_session_cookie_name(header: str, value: str) -> str | None:
"""返回值里是否携带 Hub 管理后台会话 Cookie; 是则返回 Cookie 名。"""
if str(header or "").strip().lower() != "cookie":
return None
for item in str(value or "").split(";"):
name = item.partition("=")[0].strip().lower()
if name in _HUB_SESSION_COOKIES:
return name
return None
def session_cookie_rejection(auth: dict | None, key: str = "") -> str | None:
"""拉取鉴权是否为「管理后台会话」; 是则返回面向用户的拒绝原因。
服务间定时同步必须是长期凭据。管理后台会话最长 24 小时, 过期后所有拉取
都会 401; 换机器或重新部署也无从复用。保存配置、保存 Key 与实际发请求
三处都调用本函数, 让这条纪律在 UI 与后台都无法绕过。
key 为空时无法判断 (Key 可能稍后再填), 返回 None。
"""
if not key:
return None
header = str((auth or {}).get("header") or "Authorization")
session_cookie = _hub_session_cookie_name(header, key)
if not session_cookie:
return None
return (
f"拉取鉴权不能使用管理后台会话 Cookie({session_cookie}): 它有有效期, "
"过期后定时拉取会全部失败, 换机器或重新部署也无法沿用。"
"请改用服务方签发的 API Key(如 X-API-Key / Bearer), "
"并在服务方后台把对应数据接口开放给该 Key。"
)
def _apply_auth(config_id: str, auth: dict | None, url: str, headers: dict[str, str]) -> str:
"""把 secrets_store 里的 API Key 注入出站请求。
鉴权三型与自定义行情源 AuthConfig 同口径: bearer → {header: "Bearer <key>"},
header → {header: <key>}, query → ?{param}=<key>。Key 只存 secrets.json,
不落 config.json; 配置了鉴权但未设置 Key 时 fail-closed 直接报错,
避免不带凭据请求被服务端记成无效调用。返回 (可能追加了参数的) url。
auth.key_ref: 复用已有凭据而非本表专属 Key。值为 secrets.json 的字段名
(如 "fuyao_api_key"), 环境变量兜底取该名的大写形式。同一数据源被行情插件
与扩展表共用时, 用户只需在一处填 Key。
"""
from urllib.parse import quote
from app import secrets_store
from app.services.ext_data import get_ext_api_key
auth_type = str((auth or {}).get("type") or "none").lower()
if auth_type == "none":
return url
key_ref = str((auth or {}).get("key_ref") or "").strip()
if key_ref:
key = secrets_store.get_env_backed_secret(key_ref, key_ref.upper())
if not key:
raise ValueError(
f"已配置鉴权但未找到凭据 {key_ref} — 请在 设置 → 数据源 配置对应 API Key"
)
else:
key = get_ext_api_key(config_id)
if not key:
raise ValueError(f"已配置 {auth_type} 鉴权但未设置 API Key, 请在拉取设置中填写")
header_name = str((auth or {}).get("header") or "Authorization")
rejected = session_cookie_rejection(auth, key)
if rejected:
raise ValueError(rejected)
if auth_type == "bearer":
headers[header_name] = f"Bearer {key}"
elif auth_type == "header":
headers[header_name] = key
elif auth_type == "query":
name = str(auth.get("param") or "token")
sep = "&" if "?" in url else "?"
url = f"{url}{sep}{name}={quote(key, safe='')}"
else:
raise ValueError(f"未知鉴权类型: {auth_type!r} (可选 none/bearer/header/query)")
return url
def _assert_rows_date(rows: list[dict], day: date) -> None:
"""金融契约: 响应行的 date 字段 (若提供) 必须与请求日期一致。
服务端忽略日期参数返回当日数据时会静默把当日值写进历史分区,
造成整个时序口径错乱 —— 此处 fail-closed 拒绝 (实测确实有忽略
?date= 的接口)。date 字段缺省的接口不做校验。
仅在 pull.date_param 已配置时调用 (见 _assert_rows_date_if_dated)。
"""
want = day.isoformat()
for r in rows[:20]:
if not isinstance(r, dict):
continue
raw = r.get("date")
if raw is None:
continue
if str(raw)[:10] != want:
raise ValueError(
f"接口返回的日期 {str(raw)[:10]!r} 与请求日期 {want} 不一致 "
"(接口可能不支持日期参数), 已拒绝写入该分区"
)
def _assert_rows_date_if_dated(pull: PullConfig, rows: list[dict], day: date) -> None:
"""仅对「按日查询」接口做响应日期一致性校验 (pull.date_param 已配置)。
未配置 date_param 的接口只有当日快照, 响应日期本就可能落后于请求日期
(周末/节假日/数据源 T+1), 校验会把正常快照误判成「接口忽略日期参数」
而整表拒写。该契约的目的是防历史分区被当日值污染, 对快照接口无意义。
"""
if pull.date_param:
_assert_rows_date(rows, day)
async def _request_json(
pull: PullConfig, config_id: str, day: date | None = None,
extra_params: dict[str, str] | None = None,
*,
url_override: str | None = None,
) -> Any:
"""发起一次拉取请求并返回解析后的 JSON。
正式拉取 (带日期参数) 与设置页"测试" (不带) 共用同一实现,
保证 UA 标识头与 API Key 鉴权注入只有一套口径。
extra_params: 追加的 query 参数 (分页页码/每页条数), 值经 URL 编码。
url_override: 直接指定请求地址 (两级拉取的第二级请求用), 不再套用
date_param 与 pull.url。
"""
if url_override is not None:
url = url_override
else:
url = _with_date_param(pull.url, pull.date_param, day, pull.date_format) if day else pull.url
if extra_params:
url = _append_query_params(url, extra_params)
# 每配置超时 (PullConfig.timeout_seconds, 默认 30 与历史行为一致);
# getattr 兜底测试用的简化 pull 对象
timeout = getattr(pull, "timeout_seconds", None)
timeout = timeout if isinstance(timeout, (int, float)) and timeout > 0 else 30
async with _pull_client_scope() as client:
headers = outbound_headers(pull.headers)
url = _apply_auth(config_id, pull.auth, url, headers)
kwargs: dict[str, Any] = {"headers": headers, "timeout": timeout}
if pull.method.upper() == "POST" and pull.body:
kwargs["content"] = pull.body
if "content-type" not in {k.lower() for k in headers}:
kwargs["headers"]["Content-Type"] = "application/json"
resp = await client.request(pull.method.upper(), url, **kwargs)
status_code = getattr(resp, "status_code", None)
if status_code in (401, 403):
# 鉴权失败几乎都是凭据类型/权限不对, 给出可执行提示而不是裸 HTTP 错误。
detail = (getattr(resp, "text", "") or "").strip()[:160]
raise ValueError(
f"鉴权被拒绝 (HTTP {status_code}): {detail or '无响应体'} — "
"请确认使用服务方签发的 API Key, 且该 Key 已被授权访问此接口"
)
resp.raise_for_status()
try:
return resp.json()
except Exception as e:
raise ValueError(f"响应不是有效 JSON: {e}") from e
def _append_query_params(url: str, params: dict[str, str]) -> str:
"""把追加参数并入 URL query (值经编码, 保留既有参数与 fragment)。"""
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
parts = urlsplit(url)
merged = dict(parse_qsl(parts.query, keep_blank_values=True))
merged.update({str(k): str(v) for k, v in params.items()})
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(merged), parts.fragment))
def _parse_rows_payload(config: ExtConfig, pull: PullConfig, data: Any) -> list[dict]:
"""单次响应 → 行列表 (提取/字段映射)。与写入链共用, 不含日期校验。
两级拉取 (pull.fanout) 不走这里 —— 它的行由多个响应聚合而来, 见
fetch_rows_for_date 的分支。
"""
rows = _extract_rows(data, pull.response_path)
# 字段映射
return _apply_field_map(rows, pull.field_map)
async def fetch_rows_for_date(config: ExtConfig, target_date: date, data_dir=None) -> list[dict]:
async with _pull_client_scope():
return await _fetch_rows_for_date(config, target_date, data_dir)
async def _fetch_rows_for_date(config: ExtConfig, target_date: date, data_dir=None) -> list[dict]:
"""按日期请求外部 API 并解析为行 (不写盘)。空数据返回 []。
与 fetch_and_ingest 共用同一解析链 (response_path/字段映射/关联字段校验),
历史回补与当日拉取不产生第二套口径。
配置了 pull.page_param 时按页循环拉取 (仅 GET), 空页/短页/达 max_pages 停。
配置了 pull.fanout 时走两级拉取 (目录 → 逐条目明细), 见 ext_fanout。
传入 data_dir 且配置了 pull.since_param 时带增量游标 (?since=本地最大盘次);
历史回补不传 data_dir → 恒全量 (回补只拉缺失分区, 游标无意义)。
"""
pull = config.pull
if not pull or not pull.url:
raise ValueError("拉取未配置或 URL 为空")
cursor = since_cursor(data_dir, config, target_date) if data_dir is not None else None
since_extra: dict[str, str] | None = {pull.since_param: cursor} if cursor else None
if pull.fanout:
from app.services.ext_fanout import fetch_fanout_rows
rows = await fetch_fanout_rows(config, target_date)
elif pull.page_param and pull.method.upper() == "GET":
collected: list[dict] = []
page = pull.page_start
for _ in range(pull.max_pages):
# since 是结果集过滤器, 与页码参数一起每页携带
extra: dict[str, str] = {**(since_extra or {}), pull.page_param: str(page)}
if pull.page_size_param and pull.page_size > 0:
extra[pull.page_size_param] = str(pull.page_size)
data = await _request_json(pull, config.id, day=target_date, extra_params=extra)
page_rows = _parse_rows_payload(config, pull, data)
if not page_rows:
break
collected.extend(page_rows)
# 短页 (不足一页) 说明已是最后一页, 免去再一次空请求
if pull.page_size > 0 and len(page_rows) < pull.page_size:
break
page += 1
else:
logger.warning(
"ext pull %s: 分页拉取达到 max_pages=%d 上限, 结果可能不完整",
config.id, pull.max_pages,
)
rows = collected
else:
data = await _request_json(pull, config.id, day=target_date, extra_params=since_extra)
rows = _parse_rows_payload(config, pull, data)
# 校验可关联标的的字段:直接 symbol/code,或配置里声明的映射源列。
# 市场级表 (market_level, 行=全市场一条, 无标的列) 跳过该校验。
if config.market_level:
_assert_rows_date_if_dated(pull, rows, target_date)
return rows
row_keys = set(rows[0]) if rows else set()
mapped_cols = {
m.get("col")
for m in (config.symbol_map or {}, config.code_map or {})
if m.get("type") == "mapped" and m.get("col")
}
if rows and not ({"symbol", "code"} & row_keys or mapped_cols & row_keys):
raise ValueError("数据行中缺少 symbol/code 字段,请配置字段映射或标的映射")
_assert_rows_date_if_dated(pull, rows, target_date)
return rows
async def fetch_and_ingest(
config: ExtConfig,
data_dir,
target_date: date | None = None,
*,
keep_strategy_cache: bool = False,
) -> tuple[int, str]:
"""执行一次拉取: 请求外部 API → 解析响应 → 写入 Parquet。
target_date 默认当日; 历史回补传入目标日期 (写入对应分区)。
keep_strategy_cache=True 由定时拉取循环传入: 例行刷新不清策略结果缓存。
Returns:
(rows_written, date_str)
"""
# 同上: 落盘分区按北京日期, 否则 UTC 容器在北京时间 08:00 之前写的是前一天。
day = target_date or cn_today()
rows = await fetch_rows_for_date(config, day, data_dir)
if not rows:
raise ValueError("提取到的行数为 0")
n = await run_ext_data_io(
rows_to_parquet, rows, config, data_dir, snapshot_date=day,
keep_strategy_cache=keep_strategy_cache,
)
return n, day.isoformat()
MAX_BACKFILL_DAYS = 120 # 单次回补上限: 同步端点, 控制请求时长
_BACKFILL_DAY_INTERVAL_S = 0.3 # 相邻请求间隔 (对数据源限速)
_BACKFILL_429_WAIT_S = 30.0 # 429 限流退避时长 (服务端按分钟配额)
_BACKFILL_MAX_CONSECUTIVE_429 = 3 # 连续 429 天数达到阈值 → 中止本次回补
_RATE_LIMIT_ABORT_REASON = "限流中止 (429), 稍后重跑回补可自动续补剩余日期"
def _status_code(e: BaseException) -> int | None:
"""从 httpx.HTTPStatusError 提取状态码; 非该类异常返回 None。"""
resp = getattr(e, "response", None)
return getattr(resp, "status_code", None)
def _day_partition(data_dir, config_id: str, day: date) -> Path:
return Path(data_dir) / "ext_data" / config_id / "timeseries" / f"date={day.isoformat()}" / "part.parquet"
async def backfill_history(
config: ExtConfig,
data_dir,
start: date,
end: date,
) -> dict:
async with _pull_client_scope():
return await _backfill_history(config, data_dir, start, end)
async def _backfill_history(
config: ExtConfig,
data_dir,
start: date,
end: date,
) -> dict:
"""按本地交易日逐日回补 timeseries 历史分区 (幂等, 已有分区跳过)。
前提: 接口支持按日期查询 (pull.date_param 已配置)。交易日取本地日K
分区日期 —— 非交易日无人气数据, 也避免无谓请求。单日失败不中断,
汇总进 failed 清单返回; 该日无数据 (空响应或 404) 计入 empty 跳过。
"""
if config.mode != "timeseries":
raise ValueError("仅 timeseries 模式支持历史回补 (snapshot 无历史概念)")
pull = config.pull
if not pull or not pull.url:
raise ValueError("拉取未配置或 URL 为空")
if not pull.date_param:
raise ValueError("接口未配置日期参数 (date_param) —— 需接口支持 ?日期参数= 历史查询")
if start > end:
raise ValueError("开始日期不能晚于结束日期")
if (end - start).days + 1 > MAX_BACKFILL_DAYS:
raise ValueError(f"单次回补上限 {MAX_BACKFILL_DAYS} 天, 请分段执行")
from app.services.dragon_tiger import _local_trading_days
days = [d for d in await run_ext_data_io(_local_trading_days, data_dir) if start <= d <= end]
if not days:
raise ValueError("范围内无本地交易日 (需先同步日K以确定交易日历)")
fetched = skipped = empty = 0
rows_written = 0
failed: list[dict] = []
consecutive_429 = 0 # 连续限流天数 (重试成功即清零); 达到阈值中止本次回补
for i, d in enumerate(days):
part = _day_partition(data_dir, config.id, d)
if part.exists():
skipped += 1
continue
try:
rows = await fetch_rows_for_date(config, d)
consecutive_429 = 0
if not rows:
empty += 1 # 该日无数据 (服务端未归档), 不是错误
else:
rows_written += await run_ext_data_io(rows_to_parquet, rows, config, data_dir, snapshot_date=d)
fetched += 1
except httpx.HTTPStatusError as e:
if _status_code(e) == 404:
# 接口契约 (tickflow-hub /exports、/fuyao-rank): 该日无快照
# 返回 404 —— 视为该日无数据跳过, 不计入失败
empty += 1
elif _status_code(e) != 429:
failed.append({"date": d.isoformat(), "reason": str(e)[:200]})
else:
# 服务端按分钟配额限流: 退避后原地重试一次; 连续多日 429
# 说明配额窗口已耗尽, 中止剩余天数 (幂等, 重跑即可续补)。
consecutive_429 += 1
if consecutive_429 >= _BACKFILL_MAX_CONSECUTIVE_429:
remaining = [dd for dd in days[i:] if not _day_partition(data_dir, config.id, dd).exists()]
failed.extend({"date": dd.isoformat(), "reason": _RATE_LIMIT_ABORT_REASON}
for dd in remaining)
break
await asyncio.sleep(_BACKFILL_429_WAIT_S)
try:
rows = await fetch_rows_for_date(config, d)
consecutive_429 = 0
if not rows:
empty += 1
else:
rows_written += await run_ext_data_io(rows_to_parquet, rows, config, data_dir, snapshot_date=d)
fetched += 1
except Exception as e2:
if _status_code(e2) == 404: # 退避重试后无该日快照 → 同样视为无数据
empty += 1
else:
failed.append({"date": d.isoformat(), "reason": str(e2)[:200]})
except Exception as e:
failed.append({"date": d.isoformat(), "reason": str(e)[:200]})
if i + 1 < len(days):
await asyncio.sleep(_BACKFILL_DAY_INTERVAL_S) # 限速, 对数据源礼貌
return {
"total_days": len(days),
"fetched": fetched,
"skipped_existing": skipped,
"empty": empty,
"failed": failed,
"rows_written": rows_written,
}
# ---------------------------------------------------------------------------
# 调度器
# ---------------------------------------------------------------------------
# 每日固定时刻模式的等待分片: 长睡到明天会让「中途改配置/停用」最长等一整天
# 才生效, 分片轮询让变更在一分钟内被外层重读
_DAILY_POLL_SECONDS = 60
def _today_target(now: datetime, hhmm: str) -> datetime:
"""北京时间 now 当天的固定时刻 (保持 now 的时区信息)。"""
return now.replace(hour=int(hhmm[:2]), minute=int(hhmm[3:5]), second=0, microsecond=0)
def _ran_at_or_after(pull: PullConfig, target: datetime) -> bool:
"""上次执行 (last_run, UTC ISO) 是否已不早于 target — 重启后不重复拉当天。
解析失败按「未拉过」处理: 拉取本身幂等 (同日分区去重覆盖), 宁可重拉一次
也不要因为状态字段脏了而整天静默跳过。
"""
raw = pull.last_run
if not raw:
return False
try:
dt = datetime.fromisoformat(str(raw))
except ValueError:
return False
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(CN_TZ) >= target
def _is_scheduled_day(now: datetime, pull: PullConfig) -> bool:
"""今天 (北京时间) 是否为固定时刻模式的计划日: 周期 + 交易日过滤。
weekly: schedule_day 1=周一..7=周日, 越界 (手改 config.json) 视为 1;
monthly: schedule_day 1..31, 短月无该日则当月自然不命中 (该月跳过);
trading_only: 跳过周六日 — 法定节假日无法离线判定, 仍会按期尝试,
拉取幂等且失败仅记 last_status, 不影响后续调度。
"""
period = pull.schedule_period or "daily"
if period == "weekly":
day = pull.schedule_day if 1 <= (pull.schedule_day or 1) <= 7 else 1
if now.isoweekday() != day:
return False
elif period == "monthly":
day = pull.schedule_day if 1 <= (pull.schedule_day or 1) <= 31 else 1
if now.day != day:
return False
return not (pull.schedule_trading_only and now.isoweekday() > 5)
def _next_scheduled_target(now: datetime, hhmm: str, pull: PullConfig) -> datetime:
"""从 now 起下一个计划日的固定时刻 (供 next_run 预告与睡眠)。
今天是计划日且时刻未到 → 今天; 否则从明天起逐日推进。防御上限 400 天:
最苛刻组合 (每月 31 号 + 仅交易日) 最坏跨近一年, 超限回退明天 (不可达分支)。
"""
tgt = _today_target(now, hhmm)
if _is_scheduled_day(now, pull) and now < tgt:
return tgt
for i in range(1, 400):
candidate = _today_target(now + timedelta(days=i), hhmm)
if _is_scheduled_day(candidate, pull):
return candidate
return tgt + timedelta(days=1)
class PullScheduler:
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。
线程安全说明:
refresh()/stop() 可能从主事件循环 (lifespan startup) 或同步路由的
worker 线程 (configure_pull 是 def 而非 async def, FastAPI 丢进线程池)
调用。worker 线程里没有 running loop, 直接 asyncio.create_task 会抛
"no running event loop"。因此对 task 的增删一律通过
call_soon_threadsafe 提交到主循环执行 —— 同一套代码两种调用场景都安全。
"""
def __init__(self) -> None:
self._tasks: dict[str, asyncio.Task] = {}
self._running = False
self._loop: asyncio.AbstractEventLoop | None = None
def start(self, data_dir) -> None:
"""启动调度(在 lifespan startup 调用,主事件循环内)。"""
self._running = True
self._data_dir = data_dir
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
logger.info("PullScheduler started")
def _submit(self, fn, *args) -> None:
"""把一个 callable 提交到主事件循环执行 (线程安全)。
startup 在主循环内调用时 fn 立即排队; worker 线程调用时跨线程排队。
两者都通过 call_soon_threadsafe, 保证 _tasks 字典的读写只在主循环里发生。
"""
loop = self._loop
if loop is None or loop.is_closed():
raise RuntimeError(
"PullScheduler: 事件循环不可用 (start() 未在事件循环中调用?)"
)
loop.call_soon_threadsafe(fn, *args)
def stop(self) -> None:
"""停止所有任务 (从 shutdown 调用)。"""
self._running = False
for task in self._tasks.values():
task.cancel()
self._tasks.clear()
logger.info("PullScheduler stopped")
def refresh(self, data_dir) -> None:
"""重新加载配置,更新调度任务(增/删/改)。线程安全。"""
self._data_dir = data_dir
store = ExtConfigStore(data_dir)
configs = store.load_all()
active_ids: set[str] = set()
enabled_configs: list[ExtConfig] = []
for config in configs:
if not config.pull or not config.pull.enabled or not config.pull.url:
continue
active_ids.add(config.id)
enabled_configs.append(config)
# 对 _tasks 的一切读判断 (含增删 diff) 都放进主循环闭包里执行:
# refresh 可能从工作线程调用, 若在调用方线程读 _tasks 再把决策
# 提交回主循环, 两步之间主循环可能已改动字典 (TOCTOU, #203)。
# 此处只携带与 _tasks 无关的 config 数据跨线程。
def _apply() -> None:
for config in enabled_configs:
if config.id not in self._tasks:
self._tasks[config.id] = self._loop.create_task(
self._run_loop(config)
)
logger.info(
"PullScheduler: scheduled %s (every %d min)",
config.id, config.pull.schedule_minutes,
)
for cid in [c for c in self._tasks if c not in active_ids]:
task = self._tasks.pop(cid)
task.cancel()
logger.info("PullScheduler: removed %s", cid)
self._submit(_apply)
async def _execute_pull(self, fresh: ExtConfig, store: ExtConfigStore) -> None:
"""执行一次拉取并把成功/失败状态写回配置 (间隔制与固定时刻制共用)。"""
try:
# 例行定时刷新: 不清策略结果缓存 (见 invalidate_ext_caches),
# 否则策略页每轮拉取后整页空白, 直到下次全量重算完成。
n, d = await fetch_and_ingest(
fresh, self._data_dir, keep_strategy_cache=True
)
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "success"
fresh.pull.last_message = f"{n} rows @ {d}"
fresh.pull.last_rows = n
await run_ext_data_io(store.upsert, fresh, keep_strategy_cache=True)
logger.info("PullScheduler: %s success, %d rows", fresh.id, n)
except Exception as e:
fresh2 = await run_ext_data_io(store.get, fresh.id)
if fresh2 and fresh2.pull:
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh2.pull.last_status = "error"
fresh2.pull.last_message = str(e)[:200]
await run_ext_data_io(store.upsert, fresh2, keep_strategy_cache=True)
logger.warning("PullScheduler: %s error: %s", fresh.id, e)
async def _run_daily_at(self, config_id: str, store: ExtConfigStore, hhmm: str) -> None:
"""固定时刻 (北京时间) 周期性拉取: 每天 / 每周 / 每月 / 仅交易日。
间隔制 (schedule_minutes) 锁相在启用时刻 —— 表达不了「每天 09:30 拉一次」;
这里按墙钟对齐: 今天是计划日且当前时刻已过当日目标、上次执行早于它
(重启安全) 就拉, 否则分片睡到下一个计划日的目标时刻。启用即对齐: 若
启用时已过当日时刻、当日是计划日且未拉过, 立即补拉一次 (与间隔制
「启用即拉取」同语义, 拉取本身幂等)。周期/交易日中途修改在一分钟内
被下轮 fresh 重读生效。本模式忽略时间窗口与调度间隔 (固定时刻本身
就是完整日程)。
"""
while self._running:
fresh = await run_ext_data_io(store.get, config_id)
if (
not fresh or not fresh.pull or not fresh.pull.enabled
or fresh.pull.schedule_time != hhmm
):
return # 停用 / 改时刻 / 切回间隔制 → 外层循环重读
now = cn_now()
target = _today_target(now, hhmm)
if (
_is_scheduled_day(now, fresh.pull)
and now >= target and not _ran_at_or_after(fresh.pull, target)
):
await self._execute_pull(fresh, store)
continue
nxt = _next_scheduled_target(now, hhmm, fresh.pull)
# 预告下次运行时间 (UTC ISO, 与间隔制同口径), 供前端展示
nxt_iso = nxt.astimezone(UTC).isoformat()
latest = await run_ext_data_io(store.get, config_id)
if latest and latest.pull and latest.pull.next_run != nxt_iso:
latest.pull.next_run = nxt_iso
await run_ext_data_io(store.upsert, latest, keep_strategy_cache=True)
await asyncio.sleep(
min(_DAILY_POLL_SECONDS, max((nxt - now).total_seconds(), 1.0))
)
async def _sleep_interval_reloading(
self, config_id: str, store: ExtConfigStore, interval: float,
) -> None:
"""间隔制睡眠, 但每 60s 重读配置。
用户把间隔制改成固定时刻 (或改间隔/停用) 时, 旧实现要等整段间隔睡完
才生效 —— 24h 间隔意味着新时刻要推迟一天。这里分片睡眠, 让配置改动
在一分钟内生效。
"""
remaining = float(interval)
while remaining > 0 and self._running:
chunk = min(_DAILY_POLL_SECONDS, remaining)
await asyncio.sleep(chunk)
remaining -= chunk
fresh = await run_ext_data_io(store.get, config_id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
return
if fresh.pull.schedule_time or max(fresh.pull.schedule_minutes * 60, 60) != interval:
return # 调度形态或间隔变了 → 回外层重读并重新安排
async def _run_loop(self, config: ExtConfig) -> None:
"""单个配置的定时拉取循环。
策略: 启用后立即执行一次, 之后按 interval 循环。
每次循环重读最新配置 (fresh), interval 取自 fresh.pull.schedule_minutes,
这样用户中途修改间隔也能立即生效 (无需重启)。
配置了 schedule_time (每日固定时刻) 时走 _run_daily_at 墙钟对齐。
"""
try:
while self._running:
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
store = ExtConfigStore(self._data_dir)
fresh = await run_ext_data_io(store.get, config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
pull = fresh.pull
if pull.schedule_time:
await self._run_daily_at(config.id, store, pull.schedule_time)
continue
# 时间窗口检查: 不在窗口内则跳过本次拉取
if not _in_time_window(pull.time_window_start, pull.time_window_end):
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "skipped"
fresh.pull.last_message = "不在拉取时间窗口内"
await run_ext_data_io(store.upsert, fresh, keep_strategy_cache=True)
logger.info("PullScheduler: %s skipped (outside time window)", config.id)
interval = max(pull.schedule_minutes * 60, 60)
await self._sleep_interval_reloading(config.id, store, interval)
continue
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
await self._execute_pull(fresh, store)
# 间隔取自最新配置 (每次重新读取, 修复改间隔不生效)
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
# 预告下次运行时间, 供前端展示
next_dt = datetime.now(UTC).timestamp() + interval
latest = await run_ext_data_io(store.get, config.id)
if latest and latest.pull:
latest.pull.next_run = datetime.fromtimestamp(
next_dt, tz=UTC
).isoformat()
await run_ext_data_io(store.upsert, latest, keep_strategy_cache=True)
await self._sleep_interval_reloading(config.id, store, interval)
if not self._running:
break
except asyncio.CancelledError:
pass
# 全局单例
pull_scheduler = PullScheduler()