分区行情 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>
47 lines
1.9 KiB
Python
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)
|