mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
@@ -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:]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user