Files
tick-stock-panel/backend/app/services/fs_utils.py
T
kevin9327andClaude Opus 5 b4f2dc83c9 fix(storage): 扩展数据、标的维表、财务表、主线/情绪时序与标的池缓存改用原子写 parquet
分区行情 parquet 早已统一走「临时文件 + 替换」(repository / kline_sync 的
_atomic_write_parquet), 注释写明直接 write_parquet(out) 在进程被 kill -9 或
断电时会留下半截文件。以下持久化文件仍是直接覆盖写:

- ext_data.write_ext_parquet / fix_symbol_format: 用户上传/推送的扩展数据,
  半截文件让下一次写入走「合并去重失败, 将覆盖写入」, 静默丢掉已有行
- instrument_sync.sync_instruments / enrich_names_from_quotes: 标的维表
- financial_sync._write_table: 财务表
- market_mainline.upsert_mainline_history: 主线历史 (读旧→合并→写回)
- regime_builder.refresh_phase_labels / upsert_regime_history: 情绪时序,
  半截文件让 load_regime_history 返回空表, 下一次 upsert 只写回本批新行
- tickflow.pools.get_pool: 标的池缓存, 半截文件后每次 read_parquet 都抛错

fs_utils 新增 atomic_write_parquet (与 atomic_write_text 同处, 替换沿用
replace_with_retry), 上述 9 处改用它。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:02:10 +09:00

47 lines
1.9 KiB
Python

"""文件系统小工具 — 原子写等。
历史遗留: json_report_store / strategy_cache / kline_sync 等模块里各有一份内联的
同款原子写。新代码统一用本模块的 atomic_write_text, 一处实现一处维护。
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING: # 只做类型标注; JSON 原子写的调用方 (preferences/secrets) 不必加载 polars
import polars as pl
def atomic_write_text(path: Path, text: str, *, mode: int | None = None) -> None:
"""临时文件 + os.replace 原子替换, 避免读侧读到半截 JSON。
`mode` 在替换*之前*打到临时文件上。凭证类文件如果先落盘再 chmod, 中间有一段
以默认权限存在的窗口; 先改临时文件就没有这个窗口。Windows 上 chmod 只影响
只读位, 失败不该让写入失败, 所以吞掉 OSError, 与原调用处的处理一致。
"""
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(text, encoding="utf-8")
if mode is not None:
try:
os.chmod(tmp, mode)
except OSError:
pass
os.replace(tmp, path)
def atomic_write_parquet(df: pl.DataFrame, path: Path) -> None:
"""parquet 版原子写: 先写 `<name>.tmp` 再替换, 与 repository / kline_sync 的
`_atomic_write_parquet` 同语义。
直接 `df.write_parquet(path)` 在进程被 kill (dev.sh 清端口用 kill -9)、断电或
磁盘写满时会留下半截文件, 之后读侧 `read_parquet` / `scan_parquet` 整条报错。
`.tmp` 后缀不匹配 `*.parquet` glob, 不会被视图误读。Windows 下目标正被并发读取时
由 `replace_with_retry` 短退避穿过。
"""
from app.tickflow.repository import replace_with_retry # 惰性导入, 避免模块级环
tmp = path.with_name(path.name + ".tmp")
df.write_parquet(tmp)
replace_with_retry(tmp, path)