fix(data): akshare 三表下载鲁棒性 — 原子写 + --repair (G4)

write_parquet_and_marker 改原子写(tmp→os.replace→marker) kill 不产残缺 parquet; 新增 --repair 只重取 missing/empty/corrupt 忽略 marker(周度补漏不必等财报季 --force 全量); is_parquet_healthy 辅助。top_holders 修复无回归, 106 tests。
This commit is contained in:
2026-07-28 20:36:11 +08:00
parent f94a145587
commit 670498ab01
2 changed files with 522 additions and 5 deletions
@@ -386,20 +386,63 @@ def load_done_units(data_type: str) -> set:
return done
# 空 parquet 阈值: 文件 <1KB 视为 empty/corrupt (有效 parquet 哪怕 0 行也 >1KB
# 因为有 schema/metadata; 真正 0 字节或几十字节的肯定是异常).
EMPTY_PARQUET_MIN_BYTES = 1024
def is_parquet_healthy(parquet_path: Path) -> bool:
"""判断 parquet 是否值得保留 (有数据)。返 True 健康 / False 需重取。
unhealthy 条件 (任一):
- 文件不存在 (orphan marker / 被删)
- size < EMPTY_PARQUET_MIN_BYTES (残缺或 0 字节)
- pd.read_parquet 抛异常 (corrupt magic byte 等)
- 读后 df.empty (空数据, 如北交所 akshare 不覆盖)
--repair 模式下用此函数决定是否忽略 marker 强制重取。
"""
try:
if not parquet_path.exists():
return False
if parquet_path.stat().st_size < EMPTY_PARQUET_MIN_BYTES:
return False
df = pd.read_parquet(parquet_path)
return not df.empty
except Exception:
return False
def write_parquet_and_marker(
df: pd.DataFrame,
parquet_path: Path,
) -> bool:
"""写 parquet + marker。返 True 成功 / False 失败。"""
"""原子写 parquet + marker。返 True 成功 / False 失败。
原子语义: 先写 .parquet.tmp → os.replace 到正式路径 → 写 marker。
进程被 kill / 断电时:
- to_parquet 中断: 只留 .tmp (正式路径未触碰, 旧版本数据保留)
- os.replace 中断: 同上 (replace 是原子操作, 要么完成要么没发生)
- marker 未写: 下次非 --force 会重试 (marker 是断点续传真相源)
失败时清理 .tmp 残骸 (replace 失败时 .tmp 还在; 成功后 .tmp 已消失)。
"""
tmp_path = parquet_path.with_suffix(parquet_path.suffix + ".tmp")
try:
parquet_path.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(parquet_path, index=False)
df.to_parquet(tmp_path, index=False)
os.replace(tmp_path, parquet_path)
# marker 仅在 replace 成功后写 (replace 是 Linux/Windows 上的原子操作)
marker_path_for(parquet_path).write_text(
datetime.datetime.now().isoformat()
)
return True
except Exception as e:
logger.error("写入 %s 失败: %s", parquet_path, e)
# 清理 .tmp 残骸 (replace 失败时它还在; 成功后它已消失)
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return False
@@ -612,6 +655,7 @@ def download_one_unit(
unit_id: str,
fetch_fn: Callable[[], pd.DataFrame],
force: bool,
repair: bool = False,
) -> Tuple[str, int]:
"""通用单 unit 下载: 拉 df → 写 parquet + marker。
@@ -619,12 +663,24 @@ def download_one_unit(
各 fetch_xxx 已把 None 转空 df; 这里 df 永远非 None 但可能空)。
返 (status, rows), status ∈ {'ok', 'skipped', 'empty', 'failed'}。
跳过策略 (优先级从高到低):
- force=True: 总是重取 (忽略 marker 与 repair)
- repair=True: marker 在但 parquet unhealthy (missing/empty/corrupt) 仍重取;
marker 在且 parquet 健康 → skip
- 默认 (force=repair=False): marker 在 → skip (空文件也标 done, 避免全量重跑)
"""
parquet_path = parquet_path_per_unit(data_type, unit_id)
marker_path = marker_path_for(parquet_path)
if not force and marker_path.exists():
return "skipped", 0
if repair and not is_parquet_healthy(parquet_path):
logger.info(
"[%s] %s --repair: parquet unhealthy (missing/empty/corrupt), 重取",
data_type, unit_id,
)
else:
return "skipped", 0
try:
df = fetch_fn()
@@ -661,11 +717,20 @@ def run_one_type(
done_set = load_done_units(data_type)
if args.force:
todo = [(uid, fn) for uid, fn in units]
elif getattr(args, "repair", False):
# repair 模式: 只处理 missing/empty/corrupt 的 unit
# (marker 在但 parquet unhealthy → 重取; marker 在且健康 → 跳过)
todo = [
(uid, fn) for uid, fn in units
if not (uid in done_set
and is_parquet_healthy(parquet_path_per_unit(data_type, uid)))
]
else:
todo = [(uid, fn) for uid, fn in units if uid not in done_set]
logger.info(
"[%s] 待处理 %d (已完成 %d, 总 %d)",
"[%s] 待处理 %d (已完成 %d, 总 %d%s)",
data_type, len(todo), len(done_set), len(units),
", repair 模式" if getattr(args, "repair", False) else "",
)
stats = {"ok": 0, "skipped": 0, "empty": 0, "failed": 0, "rows": 0}
@@ -676,7 +741,10 @@ def run_one_type(
for i, (uid, fn) in enumerate(todo):
try:
status, rows = download_one_unit(data_type, uid, fn, args.force)
status, rows = download_one_unit(
data_type, uid, fn, args.force,
repair=getattr(args, "repair", False),
)
except Exception as e:
status, rows = "failed", 0
logger.debug("[%s] %s 异常: %s", data_type, uid, e)
@@ -897,6 +965,11 @@ def parse_args() -> argparse.Namespace:
help="限制处理股票数 (per-stock 类生效), 测试用",
)
p.add_argument("--force", action="store_true", help="强制重下, 忽略 marker")
p.add_argument(
"--repair", action="store_true",
help="只重取 missing/empty(size<1KB 或 df.empty)/corrupt 的 parquet, "
"忽略其 marker。适合周度补漏, 不必等财报季 --force 全量重跑",
)
return p.parse_args()