chore: 数据获取耗时诊断日志 + 指数卡片涨跌着色 (#76)

* chore(diag): 数据获取全链路加分段耗时日志, 定位 WSL 卡死

用户反馈 WSL 下数据获取超时 800s 疑似卡死。在三个可能卡住的环节
加轻量诊断日志(不改业务逻辑), 复现一次即可从日志定位根因:

- policy.py: 能力探测每个 cap 的开始/完成/耗时/结果 + 探测总耗时
  (启动期 13 次串行探测是首要嫌疑)
- client.py: 3 个 SDK 客户端创建时记录端点 + 标注 SDK 默认超时
  (timeout=30s, max_retries=3, 单次最坏 127s)
- quote_service.py: 全市场行情/核心指数拉取的条数 + 耗时
- bridge.py: stock-sdk 桥接 subprocess 的 op + 耗时 + 超时上下文

* style(sidebar): 指数卡片价格按涨跌着色, 与涨跌幅同色
This commit is contained in:
wshy
2026-07-08 15:15:30 +08:00
committed by GitHub
parent f273349cc8
commit f0a082b01a
5 changed files with 40 additions and 4 deletions
+8 -1
View File
@@ -13,6 +13,7 @@ import logging
import os
import shutil
import subprocess
import time
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -47,6 +48,9 @@ def run_job(job: dict, timeout: int = DEFAULT_TIMEOUT) -> dict:
raise StockSDKBridgeError(f"桥接脚本缺失: {_BRIDGE_MJS}")
payload = json.dumps(job, ensure_ascii=False)
op = job.get("op")
_t0 = time.perf_counter()
logger.info("stock-sdk 桥接开始 (op=%s, timeout=%ss)", op, timeout)
try:
proc = subprocess.run(
[node, str(_BRIDGE_MJS)],
@@ -59,9 +63,12 @@ def run_job(job: dict, timeout: int = DEFAULT_TIMEOUT) -> dict:
cwd=str(_HERE),
)
except subprocess.TimeoutExpired as e:
raise StockSDKBridgeError(f"stock-sdk 桥接超时(op={job.get('op')}, {timeout}s)") from e
logger.warning("stock-sdk 桥接超时 (op=%s, %ss)", op, timeout)
raise StockSDKBridgeError(f"stock-sdk 桥接超时(op={op}, {timeout}s)") from e
except OSError as e:
logger.warning("stock-sdk 启动 node 失败 (op=%s): %s", op, e)
raise StockSDKBridgeError(f"启动 node 失败: {e}") from e
logger.info("stock-sdk 桥接完成 (op=%s, %.2fs)", op, time.perf_counter() - _t0)
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-800:]
+8 -2
View File
@@ -477,11 +477,17 @@ class QuoteService:
resp = []
if universes:
_u0 = time.perf_counter()
logger.info("拉取全市场行情 (universes=%s, SDK超时=30s×重试3)", universes)
resp.extend(tf.quotes.get_by_universes(universes=universes) or [])
logger.info("全市场行情拉取完成: %d 条 (%.2fs)", len(resp), time.perf_counter() - _u0)
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core":
resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or [])
_i0 = time.perf_counter()
_core_syms = sorted(core_index_symbols)
resp.extend(tf.quotes.get(symbols=_core_syms) or [])
logger.info("核心指数行情拉取完成: %d 只 (%.2fs)", len(_core_syms), time.perf_counter() - _i0)
except Exception as e: # noqa: BLE001
logger.warning("行情拉取失败: %s", e)
logger.warning("行情拉取失败 (%.2fs): %s", time.perf_counter() - t0, e)
return
if not resp:
+11
View File
@@ -10,12 +10,18 @@
"""
from __future__ import annotations
import logging
import os
from tickflow import AsyncTickFlow, TickFlow
from app import secrets_store
logger = logging.getLogger(__name__)
# SDK 默认超时配置 (见 tickflow/_base_client.py): timeout=30s, max_retries=3。
# 单次请求最坏 4×30s + 退避 ≈ 127s。日志中标注此值, 便于在卡死时对照耗时。
_sync_client: TickFlow | None = None
_async_client: AsyncTickFlow | None = None
_paid_realtime_client: TickFlow | None = None
@@ -55,8 +61,10 @@ def get_client() -> TickFlow:
if _should_use_free_server():
# none/free 档:走 free-api 服务器(无 key 或免费 key 被 SDK 忽略)
_sync_client = TickFlow.free()
logger.info("创建同步 SDK 客户端 (free-api, SDK超时=30s×重试3)")
else:
_sync_client = TickFlow(api_key=key, base_url=_base_url())
logger.info("创建同步 SDK 客户端 (付费端点=%s, SDK超时=30s×重试3)", current_endpoint())
return _sync_client
@@ -67,8 +75,10 @@ def get_async_client() -> AsyncTickFlow:
key = secrets_store.get_tickflow_key()
if _should_use_free_server():
_async_client = AsyncTickFlow.free()
logger.info("创建异步 SDK 客户端 (free-api, SDK超时=30s×重试3)")
else:
_async_client = AsyncTickFlow(api_key=key, base_url=_base_url())
logger.info("创建异步 SDK 客户端 (付费端点=%s, SDK超时=30s×重试3)", current_endpoint())
return _async_client
@@ -84,6 +94,7 @@ def get_paid_realtime_client() -> TickFlow | None:
return None
if _paid_realtime_client is None:
_paid_realtime_client = TickFlow(api_key=key, base_url=_base_url())
logger.info("创建实时行情 SDK 客户端 (付费端点=%s, SDK超时=30s×重试3)", current_endpoint())
return _paid_realtime_client
+12
View File
@@ -123,6 +123,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]:
# 探测专用客户端:强制走付费端点验证 key。
# base_url 用用户自定义端点(若已配置测速切换),否则默认 api.tickflow.org。
probe_base = _base_url() or PAID_ENDPOINT
logger.info("开始能力探测 (付费端点=%s, SDK默认超时=30s×重试3)", probe_base)
tf = TickFlow(api_key=key, base_url=probe_base)
available: dict[Cap, CapabilityLimits] = {}
log: list[str] = []
@@ -131,6 +132,9 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]:
transient_failed: set[Cap] = set()
def try_call(cap: Cap, fn, default_limits: dict[str, Any]) -> None:
# 分段耗时日志: 记录每个 cap 探测的开始/结束/耗时/结果, 便于定位卡死环节。
_t0 = time.perf_counter()
logger.info("能力探测开始: %s", cap.value)
try:
_call_with_retry(fn)
available[cap] = CapabilityLimits(
@@ -138,8 +142,11 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]:
batch=default_limits.get("batch"),
subscribe=default_limits.get("subscribe"),
)
_elapsed = time.perf_counter() - _t0
log.append(f"{cap}")
logger.info("能力探测完成: %s ✓ (%.2fs)", cap.value, _elapsed)
except Exception as e: # noqa: BLE001
_elapsed = time.perf_counter() - _t0
msg = str(e).lower()
cls = e.__class__.__name__
# PermissionError 类名 / HTTP 403 / 中英文权限关键词都算"明确无权限"
@@ -151,6 +158,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]:
)
if is_perm_denied:
log.append(f"{cap}(无权限)")
logger.info("能力探测完成: %s ✗ 无权限 (%.2fs)", cap.value, _elapsed)
elif _is_transient(e):
# 仅**真瞬时**错误(超时/连接/5xx/429, 由 _is_transient 判定)才标记为疑似 —
# 与探测重试用同一判据。否则一个消息未命中权限关键词的确定性失败
@@ -158,9 +166,11 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str], set[Cap]]:
# 保护(保留旧付费档)反而掩盖真实的 Key 失效, 永不回落到 free-api。
transient_failed.add(cap)
log.append(f"? {cap} (瞬时: {cls}: {e})")
logger.warning("能力探测瞬时失败: %s ? %s: %s (%.2fs)", cap.value, cls, e, _elapsed)
else:
# 非权限关键词、也非瞬时 → 视为该能力确实不可用(不保留、不重试保护)
log.append(f"{cap}({cls}: {e})")
logger.info("能力探测完成: %s%s: %s (%.2fs)", cap.value, cls, e, _elapsed)
# 用各档默认上限作为占位(无 X-RateLimit-* 头时)
# 取所有档的并集,逐 cap 试探
@@ -295,7 +305,9 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
# 有 API key — 真实探测
try:
_probe_t0 = time.perf_counter()
capset, probe_log, transient_failed = _probe_real(tiers)
logger.info("能力探测全部完成, 总耗时 %.2fs", time.perf_counter() - _probe_t0)
# 判定档位:无效 key → none,免费 key → free,付费 → starter/pro/expert
classified = _classify_tier(capset, tiers)
+1 -1
View File
@@ -150,7 +150,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
<span className="text-[10px] text-secondary">{item.name}</span>
<span className={`text-[10px] font-mono ${indexPctClass(pct)}`}>{fmtIndexPct(pct)}</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10px] text-foreground/80">
<div className={`mt-0.5 truncate font-mono text-[10px] ${indexPctClass(pct)}`}>
{fmtIndexValue(value)}
</div>
</NavLink>