ai-chatbot / app /services /persist.py
shuaiwang
fix: independent persist executor + reset pending_push on failure
8420e1e
Raw
History Blame
6.78 kB
"""HF Dataset repo 持久化同步.
为什么需要: HF Spaces 免费版磁盘是临时的 (容器重启后 /data 内的非持久卷数据会丢).
唯一免费的持久化方案是把 /data 同步到 HF Dataset repo (Git LFS).
调用模式:
- lifespan startup: restore_from_hf()
- 每次写操作后: schedule_push() (异步, 不阻塞用户)
- lifespan shutdown: push_to_hf() (同步, 尽量)
健壮性:
- 首次部署 (repo 不存在) → 捕获 RepositoryNotFoundError → 标记 "fresh_start"
- HF_TOKEN 缺失 → 跳过持久化, 降级为纯本地
- 网络错误 → 重试 3 次后放弃, 不阻塞业务
- upload_folder 跑在**独立 executor** 上, 不会占业务池 (否则 HF Space → HF Dataset
一旦卡/慢, ChromaDB query / BGE-M3 encode 等业务的 run_in_executor 全排不上, chat 直接挂)
- push 失败时**也重置** pending_push flag, 不然 schedule_push 永远 short-circuit
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import logging
import shutil
from pathlib import Path
from typing import Literal
from huggingface_hub import (
create_repo,
snapshot_download,
upload_folder,
)
from huggingface_hub.errors import RepositoryNotFoundError
from app.config import settings
from app.core.paths import data_dir, sqlite_dir, chroma_dir, upload_dir
logger = logging.getLogger(__name__)
# ✅ 独立 ThreadPoolExecutor, 不跟业务 (Chroma / BGE-M3 / run_in_executor) 抢线程
# 2 个 worker 够用: push 是单飞, 第二个留给 restore (启动期偶发重入)
_persist_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2,
thread_name_prefix="persist",
)
# 状态机: 持久化是否启用 / 启动模式
_state: dict[str, str | bool] = {
"mode": "disabled", # disabled | cold_restore | fresh_start
"last_push_at": 0.0,
"pending_push": False,
}
def persist_mode() -> Literal["disabled", "cold_restore", "fresh_start"]:
return _state["mode"] # type: ignore[return-value]
async def restore_from_hf() -> None:
"""从 HF Dataset repo 拉取数据到本地.
调用时机: FastAPI lifespan 启动.
"""
if not settings.is_persist_enabled():
logger.info("Persistence disabled (HF_PERSIST_REPO or HF_TOKEN not set)")
_state["mode"] = "disabled"
return
repo_id = settings.hf_persist_repo
token = settings.hf_token.get_secret_value()
local_root = data_dir()
target_subdirs = ["chroma", "sqlite", "uploads"]
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
_persist_executor, # ✅ 独立池
lambda: snapshot_download(
repo_id=repo_id,
repo_type="dataset",
local_dir=str(local_root),
token=token,
allow_patterns=[f"{d}/*" for d in target_subdirs] + target_subdirs,
),
)
_state["mode"] = "cold_restore"
logger.info("Persisted data restored from %s", repo_id)
except RepositoryNotFoundError:
# 首次部署: repo 还没创建, 属正常情况
_state["mode"] = "fresh_start"
logger.info(
"Persist repo %s not found (first deploy?). "
"Will create on first push.",
repo_id,
)
except Exception as e: # noqa: BLE001
logger.error("Persist restore failed (will start fresh): %s", e)
_state["mode"] = "fresh_start"
# 不阻塞启动, 提示用户在 /readyz 看到降级状态
async def push_to_hf() -> None:
"""同步推送本地数据到 HF Dataset repo. 阻塞."""
if not settings.is_persist_enabled():
return
if _state["mode"] == "fresh_start":
# 首次需要先 create_repo
await _ensure_repo_exists()
repo_id = settings.hf_persist_repo
token = settings.hf_token.get_secret_value()
local_root = data_dir()
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
_persist_executor, # ✅ 独立池, 不阻塞业务 (Chroma / BGE-M3) 的 run_in_executor
lambda: upload_folder(
folder_path=str(local_root),
repo_id=repo_id,
repo_type="dataset",
token=token,
commit_message=f"sync {asyncio.get_running_loop().time():.0f}",
ignore_patterns=[".cache/*", "*.tmp", "*.lock"],
),
)
_state["last_push_at"] = asyncio.get_running_loop().time()
_state["pending_push"] = False
logger.info("Persisted data pushed to %s", repo_id)
except Exception as e: # noqa: BLE001
# ✅ 失败也重置 flag, 否则 schedule_push 永远 short-circuit, 数据再也不推
_state["pending_push"] = False
logger.error("Persist push failed: %s", e)
async def schedule_push() -> None:
"""异步推送, 不阻塞业务. 多次调用合并为一次 (简单去抖).
适用: 摄入完成 / 删除文档后.
"""
if not settings.is_persist_enabled():
return
if not settings.persist_on_write:
return
if _state["pending_push"]:
return # 已有 pending, 跳过
_state["pending_push"] = True
async def _delayed_push() -> None:
# 简单去抖: 延迟 30s, 把同一秒内的多次写合并
await asyncio.sleep(30)
if _state["pending_push"]:
await push_to_hf()
asyncio.create_task(_delayed_push())
async def _ensure_repo_exists() -> None:
"""首次部署时自动创建 HF Dataset repo."""
repo_id = settings.hf_persist_repo
token = settings.hf_token.get_secret_value()
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
_persist_executor, # ✅ 独立池
lambda: create_repo(
repo_id=repo_id,
repo_type="dataset",
token=token,
private=True,
exist_ok=True,
),
)
logger.info("Created persist repo: %s", repo_id)
except Exception as e: # noqa: BLE001
logger.error("Failed to create persist repo: %s", e)
def persist_status() -> dict:
"""供 /readyz 暴露持久化状态."""
return {
"enabled": settings.is_persist_enabled(),
"mode": _state["mode"],
"pending_push": _state["pending_push"],
"repo": settings.hf_persist_repo or None,
}
def _wipe_local_data() -> None:
"""测试用: 清空本地 data 目录."""
for d in (sqlite_dir(), chroma_dir(), upload_dir()):
if d.exists():
shutil.rmtree(d, ignore_errors=True)
__all__ = [
"restore_from_hf",
"push_to_hf",
"schedule_push",
"persist_mode",
"persist_status",
]