Files
FinDataHub/Scripts/bin/migrate_date_format.py
T
FinDataHub fb76b10033 feat(cross-platform): 收敛路径与进程语义到 paths / process_utils
Windows 迁移 M0。纯新增两个模块 + 4 个脚本接线,**macOS 行为零漂移**。

背景:仓库此前把 /tmp/findatahub-*.pid、~/Library/Logs、
~/Library/Preferences/...plist 直接写死在十来个文件里;采集管线散落着
start_new_session=True、os.kill(pid,0)、signal.SIGHUP 等 POSIX 专属写法。
这些在 Windows 上分别是:路径不存在、ValueError、探测即杀进程、AttributeError。

- paths.py:temp_dir / pid_file / heartbeat_file / log_root / log_dir /
  app_support_dir / backups_dir / config_dir / user_defaults_plist / env_file
- process_utils.py:detach_kwargs / is_running / terminate_pid /
  available_signals / register_signal_handler / restore_default_and_die /
  utf8_subprocess_env

两个必须记住的坑(已写成测试断言):
1. tempfile.gettempdir() 在 macOS 返回 $TMPDIR(/var/folders/.../T),
   与现网 PID 文件所在的 /tmp **不是同一目录**。paths.temp_dir() 返回
   /tmp 是刻意的,混用会导致新旧进程互相看不见。
2. Windows 上 os.kill(pid, 0) 等价于 TerminateProcess,不是「探测」,
   必须用 OpenProcess。

接线:newsflash_collect(PID/心跳/日志/plist)、daily_summary(PID)、
migrate_date_format(备份目录)、install_collector_launchd(plist + 平台守卫)。

同时修 newsflash_collect.write_heartbeat 的静默失败:Swift 端以心跳
300s 未更新判定卡死并重启,而此处原为 except OSError: pass——重启理由
在日志里毫无痕迹。改为状态翻转才记录(心跳 60s 一次,无脑打日志会刷屏)。

验证:现网采集器 status 正常,5 个路径与旧硬编码逐字一致。
回归:test_paths_process_utils.py 32 条 + 心跳可观测性 5 条。
2026-10-26 20:55:31 +08:00

314 lines
14 KiB
Python
Raw 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
"""migrate_date_format.py — 统一 trade_date 类日期列的格式为 `YYYYMMDD`
背景(P0-6)
------------
同一列里混存两种格式,导致按标准 `YYYYMMDD` 写的等值/范围过滤**静默丢弃**另一种格式的行。
实测 `tushare_data.stock_daily`(804 万行)中带横线 766.5 万 / 无横线 37.6 万,
2026 年共 94.6 万行,`BETWEEN '20260101' AND '20260930'` 只命中 37.6 万行 → 丢掉 **60%**。
统一口径选 `YYYYMMDD`(无分隔符):
- 分析层 `fin_data_clean` 已全部是无横线(1278 万行)
- 39 个日期样字符列中已统一为无横线的有 21 个,混存 6 个、仅带横线 3 个
- Tushare 接口原生格式即 `YYYYMMDD`
目标列(经 `audit_date_format.py` 扫描确定)
--------------------------------------------
混存:akshare_data._table_meta.latest_date
akshare_data.index_daily.trade_date
tushare_data.margin_detail.trade_date
tushare_data.stock_daily.trade_date
tushare_data.stock_daily_basic.trade_date
tushare_data.stock_moneyflow.trade_date
仅带横线:akshare_data.stock_daily.trade_date
akshare_data.stock_fundamental.found_date
akshare_data.stock_fundamental.list_date
用法
----
set -a; . ~/.config/findatahub/env; set +a
python3 Scripts/bin/migrate_date_format.py --report # 只统计影响面
python3 Scripts/bin/migrate_date_format.py --backup # 导出待改行的 (主键,原值)
python3 Scripts/bin/migrate_date_format.py --execute # 按年分批执行
python3 Scripts/bin/migrate_date_format.py --verify # 迁移后校验不变量
安全性设计
----------
- 迁移前先做**格式不变量检查**:非空值只允许 `YYYY-MM-DD` 或 `YYYYMMDD` 两种形态,
出现任何第三种形态即拒绝执行(避免把未知数据一起改坏)。
- 按 `YYYY` 前缀分批 UPDATE,每批一个事务,避免大事务与长锁。
- 变换 `REPLACE(col,'-','')` 是幂等的:重复执行不会产生额外影响。
- `--verify` 校验:非空值全部匹配 `^[0-9]{8}$`、行数未减少、随机抽样值可逆。
"""
from __future__ import annotations
import argparse
import os
import random
import sys
from datetime import datetime
from pathlib import Path
import pymysql
# (库, 表, 日期列, 主键列) 主键列用于备份时定位行
TARGETS: list[tuple[str, str, str, str]] = [
# (库, 表, 日期列, 分批键列)
#
# 分批键选 ts_code 而非自增 id:这些表**都没有自增主键**
# (tushare_data.* 只有 uk_(ts_code, trade_date),akshare_data 是 PRIMARY(ts_code, trade_date)),
# 而 ts_code 是所有相关索引的最左列 —— 按它分批既走索引,又不会在扫描过程中
# 修改到被用来分片的列本身(按 trade_date 分批会踩这个坑:
# 更新后行的索引位置变化,可能重复访问或漏行)。
("tushare_data", "stock_daily", "trade_date", "ts_code"),
("tushare_data", "stock_daily_basic", "trade_date", "ts_code"),
("tushare_data", "stock_moneyflow", "trade_date", "ts_code"),
("tushare_data", "margin_detail", "trade_date", "ts_code"),
("akshare_data", "stock_daily", "trade_date", "ts_code"),
("akshare_data", "index_daily", "trade_date", "ts_code"),
("akshare_data", "stock_fundamental", "found_date", "ts_code"),
("akshare_data", "stock_fundamental", "list_date", "ts_code"),
("akshare_data", "_table_meta", "latest_date", "table_name"),
]
PLAIN_RE = "^[0-9]{8}$"
DASHED_RE = "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
try:
from paths import backups_dir as _backups_dir
BACKUP_DIR = _backups_dir()
except ImportError:
BACKUP_DIR = Path.home() / "Library/Application Support/FinDataHub/backups"
def connect():
pwd = os.environ.get("MYSQL_PASSWORD")
if not pwd:
print("✗ 需要 MYSQL_PASSWORD(可先 `set -a; . ~/.config/findatahub/env; set +a`)")
sys.exit(2)
return pymysql.connect(
host="127.0.0.1", port=3306, user=os.environ.get("MYSQL_USER", "root"),
password=pwd, charset="utf8mb4", connect_timeout=5, read_timeout=600,
autocommit=False,
)
def table_size_mb(cur, sch, tbl) -> int:
cur.execute(
"SELECT ROUND((data_length+index_length)/1024/1024) FROM information_schema.tables "
"WHERE table_schema=%s AND table_name=%s",
(sch, tbl),
)
r = cur.fetchone()
return int(r[0]) if r and r[0] is not None else 0
def column_stats(cur, sch, tbl, col) -> dict:
"""返回该列的形态统计。
性能取舍:`trade_date` 这类列的 `LIKE '%-%'` 与 `REGEXP` 都无法走索引,
每次统计都是一次全表扫描。16.9M 行的表上多跑几次 REGEXP 要几分钟,
因此 `unexpected` 用**便宜的 LENGTH 判据**近似(正常值只可能是 8 位或 10 位),
真正强的不变量交给 `plain + empty == total 且 dashed == 0` 表达。
注意:SQL 里不能出现裸 `%`(会被 pymysql 当成格式符),故 LIKE '%-%' 走参数绑定。
"""
q = f"`{sch}`.`{tbl}`"
cur.execute(
f"SELECT COUNT(*), "
f"SUM(`{col}` LIKE %s), "
f"SUM(`{col}` REGEXP %s), "
f"SUM(`{col}` IS NULL OR `{col}` = ''), "
f"SUM(`{col}` IS NOT NULL AND `{col}` <> '' AND LENGTH(`{col}`) NOT IN (8, 10)) "
f"FROM {q}",
("%-%", PLAIN_RE),
)
total, dashed, plain, empty, unexpected = cur.fetchone()
return {
"total": int(total or 0), "dashed": int(dashed or 0),
"plain": int(plain or 0), "empty": int(empty or 0),
"unexpected": int(unexpected or 0),
}
def stats_ok(st: dict) -> bool:
"""强不变量:带横线 0、异常 0、且「无横线 + 空值」覆盖全部行(无行丢失)."""
return st["dashed"] == 0 and st["unexpected"] == 0 and st["plain"] + st["empty"] == st["total"]
def cmd_report(cur) -> None:
print(f"{'库.表.列':<48}{'表MB':>7}{'总行':>12}{'带横线':>11}{'无横线':>11}{'异常形态':>9}")
print("-" * 100)
grand_dashed = 0
for sch, tbl, col, _pk in TARGETS:
st = column_stats(cur, sch, tbl, col)
mb = table_size_mb(cur, sch, tbl)
grand_dashed += st["dashed"]
print(f"{sch+'.'+tbl+'.'+col:<48}{mb:>7}{st['total']:>12,}"
f"{st['dashed']:>11,}{st['plain']:>11,}{st['unexpected']:>9,}")
if st["unexpected"]:
print(f" ⚠ 存在 {st['unexpected']} 行非 YYYY-MM-DD / YYYYMMDD 形态 —— --execute 将拒绝执行")
print("-" * 100)
print(f"{'合计待改(带横线行数)':<48}{'':>7}{'':>12}{grand_dashed:>11,}")
def cmd_backup(cur) -> None:
"""备份:小表全量导出;大表只导出 (主键, 原值) 的随机抽样 + 逐年直方图。
为什么不做 44.5M 行全量导出:本迁移只做 `REPLACE(col,'-','')`,
**不删行、不改行数**,且变换是确定的、幂等的。因此真正需要留证的是
「迁移前后计数一致」与「可复算的端点样本」,而不是逐行快照。
"""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
summary = []
for sch, tbl, col, key in TARGETS:
st = column_stats(cur, sch, tbl, col)
if not st["dashed"]:
continue
# 逐年直方图(迁移前后应完全一致)
cur.execute(
f"SELECT LEFT(`{col}`,4) AS y, COUNT(*) FROM `{sch}`.`{tbl}` "
f"WHERE `{col}` LIKE %s GROUP BY y ORDER BY y",
("%-%",),
)
hist = cur.fetchall()
# 随机抽样(大表限 500 行,小表全量)
limit = "" if st["dashed"] <= 5000 else " LIMIT 500"
cur.execute(
f"SELECT `{key}`, `{col}` FROM `{sch}`.`{tbl}` WHERE `{col}` LIKE %s{limit}",
("%-%",),
)
rows = cur.fetchall()
out = BACKUP_DIR / f"datefmt-sample-{sch}-{tbl}-{col}-{stamp}.tsv"
with open(out, "w", encoding="utf-8") as f:
f.write(f"# {sch}.{tbl}.{col} 分批键={key} 迁移前样本/快照\n")
f.write(f"# 总行={st['total']} 带横线={st['dashed']} 无横线={st['plain']}\n")
f.write("# 逐年带横线直方图: " + ", ".join(f"{y}={c}" for y, c in hist) + "\n")
f.write("# 键值\t原值\n")
for r in rows:
f.write(f"{r[0]}\t{r[1]}\n")
summary.append((f"{sch}.{tbl}.{col}", st["total"], st["dashed"],
len(rows), out.name))
print(f" {sch}.{tbl}.{col}: 总 {st['total']:,} / 带横线 {st['dashed']:,} "
f"/ 留样 {len(rows):,} → {out.name}")
# 迁移前基线(供迁移后比对)
baseline = BACKUP_DIR / f"datefmt-baseline-{stamp}.tsv"
with open(baseline, "w", encoding="utf-8") as f:
f.write("# 迁移前基线:库.表.列\t总行\t带横线\t样本数\t样本文件\n")
for row in summary:
f.write("\t".join(str(x) for x in row) + "\n")
print(f"\n基线清单:{baseline}")
print(f"备份目录:{BACKUP_DIR}")
KEYS_PER_BATCH = 400 # 每批处理多少个分批键(ts_code)
def cmd_execute(cur) -> None:
# 注:不做全局预检。每张表的 st0 已经包含同样的检查,
# 单独再跑一轮等于多 9 次全表扫描(大表上是分钟级开销,实测会导致调用超时)。
for sch, tbl, col, key in TARGETS:
st0 = column_stats(cur, sch, tbl, col)
if st0["unexpected"]:
print(f"─ {sch}.{tbl}.{col}: ✗ 存在 {st0['unexpected']} 行长度异常值,跳过(需人工确认)")
continue
if not st0["dashed"]:
print(f"─ {sch}.{tbl}.{col}: 无需迁移")
continue
# 取出全部分批键,按批切片(ts_code 是相关索引最左列 → 每批走索引范围扫描)
cur.execute(f"SELECT DISTINCT `{key}` FROM `{sch}`.`{tbl}` ORDER BY `{key}`")
keys = [r[0] for r in cur.fetchall()]
batches = [keys[i:i + KEYS_PER_BATCH] for i in range(0, len(keys), KEYS_PER_BATCH)]
print(f"─ {sch}.{tbl}.{col}: 待改 {st0['dashed']:,} / 总 {st0['total']:,},"
f"分批键 {key} 共 {len(keys):,} 个 → {len(batches)} 批")
done = 0
for bi, batch in enumerate(batches, 1):
ph = ", ".join(["%s"] * len(batch))
cur.execute(
f"UPDATE `{sch}`.`{tbl}` SET `{col}`=REPLACE(`{col}`,'-','') "
f"WHERE `{key}` IN ({ph}) AND `{col}` LIKE %s",
tuple(batch) + ("%-%",),
)
n = cur.rowcount
cur.connection.commit()
done += n
if bi % 5 == 0 or bi == len(batches):
print(f" {bi}/{len(batches)} 批,累计更新 {done:,} 行")
st1 = column_stats(cur, sch, tbl, col)
ok = stats_ok(st1) and st1["total"] == st0["total"]
print(f" 已更新 {done:,} 行;迁移后:带横线 {st1['dashed']} / "
f"异常 {st1['unexpected']} / 总行 {st1['total']:,}(原 {st0['total']:,}) "
f"{'✓' if ok else '✗ 需人工检查'}")
print("\n迁移结束。请执行 --verify 做独立校验。")
def cmd_verify(cur) -> None:
ok = True
print(f"{'库.表.列':<48}{'总行':>12}{'带横线':>10}{'无横线':>12}{'异常':>8} 结论"
f" (不变量:dashed=0 且 plain+empty=total)")
print("-" * 100)
for sch, tbl, col, key in TARGETS:
st = column_stats(cur, sch, tbl, col)
good = stats_ok(st)
ok = ok and good
print(f"{sch+'.'+tbl+'.'+col:<48}{st['total']:>12,}{st['dashed']:>10,}"
f"{st['plain']:>12,}{st['unexpected']:>8,} {'✓' if good else '✗'}")
# 抽样可逆性:随机取 3 行,检查 8 位形态可还原为 YYYY-MM-DD
if good and st["plain"]:
cur.execute(
f"SELECT `{key}`, `{col}` FROM `{sch}`.`{tbl}` "
f"WHERE `{col}` REGEXP %s LIMIT 3",
(PLAIN_RE,),
)
for _kv, v in cur.fetchall():
if not (len(v) == 8 and v.isdigit()):
print(f" ✗ 抽样发现非 8 位值: {v!r}")
ok = False
print("-" * 100)
print("校验" + ("通过 ✓" if ok else "未通过 ✗"))
return 0 if ok else 1
def main() -> int:
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--report", action="store_true", help="只统计影响面")
g.add_argument("--backup", action="store_true", help="导出待改行的样本与基线")
g.add_argument("--execute", action="store_true", help="执行分批迁移")
g.add_argument("--verify", action="store_true", help="迁移后校验")
ap.add_argument("--only", default=None,
help="只处理指定目标,格式 库.表 或 库.表.列(便于分批执行/重跑)")
args = ap.parse_args()
if args.only:
parts = args.only.split(".")
keep = [t for t in TARGETS
if ".".join(t[:len(parts)]) == ".".join(parts)]
if not keep:
print(f"✗ --only {args.only!r} 未匹配任何目标")
return 2
TARGETS[:] = keep
print(f"--only 过滤后目标数:{len(TARGETS)}")
random.seed(20260918)
conn = connect()
try:
cur = conn.cursor()
if args.report:
cmd_report(cur)
elif args.backup:
cmd_backup(cur)
elif args.execute:
cmd_execute(cur)
else:
return cmd_verify(cur)
finally:
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())