Files
FinDataHub e7186e5ce6 fix(prod): 调度脚本改用 bundle venv 直调,修复「uv run 运行期联网」导致日更整体中止
故障(2026-09-19 15:40 实测)
------------------------------
`tushare_sync.sh daily` **一张表都没抓**,日志只有一行:
    ── T0 每日同步 (日线 + 资金面 + 事件公告) ──
      table_group: daily (6 tables)
    error: Request failed after 3 retries in 4.5s
      cause: Failed to fetch: `https://pypi.tuna.tsinghua.edu.cn/simple/structlog/`
      cause: dns error
      cause: failed to lookup address information: nodename nor servname provided

根因
----
两个调度脚本都用 `uv run <entrypoint>` 调用。**`uv run` 会在运行时同步/重建依赖
环境**,需要联网下载缺失包 —— 当天该请求 DNS 解析失败;又因脚本带
`set -eo pipefail`,这条 error 直接**中止整条任务**。

手动复现同一条命令还看到额外危害:
    Building tushare-fetcher @ file:///...
    Downloading mypy (13.0MiB)
    Installed 14 packages in 31ms
即**生产运行期会联网改写自身 venv 的依赖**(把 mypy 装进生产环境)。

改动
----
`tushare_sync.sh`(17 处)与 `akshare_fetcher/scripts/sync_all.sh`(14 处)
全部改为直调 bundle 内的可执行文件:
    TUSHARE_BIN="$PROJECT_DIR/.venv/bin/tushare-fetcher"   # 原 uv run tushare-fetcher
    AKSHARE_BIN="$PROJECT_DIR/.venv/bin/akshare-fetcher"   # 原 uv run akshare-fetcher
    VENV_PY="$PROJECT_DIR/.venv/bin/python"                # 原 uv run python3 / --no-sync python
并在使用前校验可执行文件存在,缺失即明确报错退出(而不是让 uv 去"修好它")。
→ 不联网、不重建、也不依赖 uv 是否在 PATH 上(launchd 环境 PATH 极简,这本身也是风险)。

验证(端到端实跑)
------------------
- 当日日志现为:15:40 那次(第 1–8 行,含 dns error)之后,17:46:50 起新运行
  至 17:48:45 **完成,exit 0,464 行,0 error**
- 6 个 table_group 全部执行到(含本批新增的「事件公告」与「补排期-日频」两组)
- 银层迁移同批完成:11,246 行写入 fin_data_clean.stock_monthly,blocked=False
- akshare 路径(18:00 任务)冒烟通过,无 uv 报错

说明:今天为周六(非交易日),故本次故障的**数据**影响有限(无新行情可采),
但该缺陷在每个工作日都会造成**整日零采集**,属最高优先级修复。

附带提交(同日稍早完成、一并入库)
----------------------------------
- `tables_registry.json`:`us_basic` 唯一键由 ["ts_code"] 修正为
  ["ts_code","list_date","name"]。同一 ticker 会被不同公司复用(AEM/ABX/AGI 等),
  旧键导致静态表 replace 写入报 (1062, Duplicate entry),整批失败。
  已按 MySQL NULL 语义实测验证为 0 冲突,DB 侧唯一索引同步重建。
- `fut_wsr` 追加警告:接口返回行在 (trade_date,symbol,warehouse) 下可重复且
  **无自然唯一键**(同仓库同日多条仓单,vol 不同),按唯一键 upsert 会静默覆盖丢行。
- 新增 `Scripts/bin/verify_unique_keys.py`(校验 registry 声明的唯一键在真实数据上
  是否唯一,含 MySQL NULL 语义与组合搜索建议)与 `Scripts/bin/inspect_dup_rows.py`
  (打印重复行以确定真实唯一键)。
2026-09-19 17:49:33 +08:00

92 lines
3.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""inspect_dup_rows.py — 打印某张表在声明唯一键下的重复行,用于确定真实唯一键
用法(在 tushare_fetcher 目录下,用其 venv 运行):
set -a; . ~/.config/findatahub/env; set +a
unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy
./.venv/bin/python <此脚本> fut_wsr us_basic
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from pathlib import Path
import pandas as pd
FD = Path("/Applications/FinDataHub.app/Contents/Resources/tushare_fetcher")
def api_of(key: str) -> str | None:
src = (FD / "src/tushare_fetcher/core/fetcher.py").read_text(encoding="utf-8")
blk = src[src.index("TABLE_MAP"):]
m = re.search(r'^\s{8}"' + key + r'":\s*\(([^)]*)\)', blk, re.M)
if not m:
return None
quoted = re.findall(r'"([A-Za-z0-9_]+)"', m.group(1))
return quoted[1] if len(quoted) > 1 else (quoted[0] if quoted else None)
def declared_keys(key: str) -> list[str]:
entries = json.loads((FD / "tables_registry.json").read_text(encoding="utf-8"))["tables"]
for e in entries:
if e.get("key") == key:
return e.get("unique_keys") or []
return []
def main() -> int:
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
os.environ.pop(k, None)
import tushare as ts
ts.set_token(os.environ["TUSHARE_TOKEN"])
pro = ts.pro_api()
pd.set_option("display.width", 200)
pd.set_option("display.max_columns", 30)
for key in sys.argv[1:]:
api = api_of(key)
uk = declared_keys(key)
df = None
for params in ({}, {"trade_date": "20260918"}, {"ts_code": "000001.SZ"},
{"start_date": "20260901", "end_date": "20260918"}):
try:
cand = getattr(pro, api)(**params)
if cand is not None and not cand.empty:
df, used = cand, params
break
except Exception: # noqa: BLE001
pass
time.sleep(0.5)
print(f"\n{'=' * 78}\n{key} 接口={api} 入参={used if df is not None else '取样失败'}")
if df is None:
print(" 取样失败"); continue
print(f" 声明唯一键={uk} 样本 {len(df)} 行 全部列={list(df.columns)}")
if not uk or any(c not in df.columns for c in uk):
print(" 声明的唯一键列不完整,跳过"); continue
dup_mask = df.duplicated(subset=uk, keep=False)
dups = df[dup_mask].sort_values(by=uk)
print(f" 参与重复的行数: {len(dups)}(其中判定为重复的多余行 {int(df.duplicated(subset=uk).sum())})")
if len(dups):
print(" 重复样例(最多 8 行):")
print(dups.head(8).to_string(index=False))
# 对重复组内每列取值个数做统计,找出"能把组内区分开"的列
grp = dups.groupby(uk, dropna=False)
varying = []
for col in df.columns:
if col in uk:
continue
if (grp[col].nunique(dropna=False) > 1).any():
varying.append(col)
print(f" 组内取值有差异的列(候选真实唯一键成分): {varying}")
return 0
if __name__ == "__main__":
sys.exit(main())