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:
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user