appQQQ commited on
Commit
22eca22
·
verified ·
1 Parent(s): 8420e1e

fix: persist A 改良版 (verify + 5s debounce + sync push for upload)

Browse files
Files changed (2) hide show
  1. app/services/ingestion.py +15 -3
  2. app/services/persist.py +116 -42
app/services/ingestion.py CHANGED
@@ -41,7 +41,7 @@ from app.services.chunking import (
41
  from app.services.embedding import get_embedder
42
  from app.services.parsers import parse_with_fallback
43
  from app.services.parsers.base_parser import ParsedDocument
44
- from app.services.persist import schedule_push
45
  from app.services.vector_store import upsert_chunks
46
 
47
  logger = logging.getLogger(__name__)
@@ -261,8 +261,18 @@ async def _ingest_locked(content: bytes, filename: str) -> IngestResult:
261
  filename, doc_id, len(chunking.children), elapsed_ms,
262
  )
263
 
264
- # 11. 调度持久化推送
265
- await schedule_push()
 
 
 
 
 
 
 
 
 
 
266
 
267
  return IngestResult(
268
  doc_id=doc_id,
@@ -308,6 +318,8 @@ async def delete_document(doc_id: str) -> bool:
308
  shutil.rmtree(up_dir, ignore_errors=True)
309
 
310
  await schedule_push()
 
 
311
  return True
312
 
313
 
 
41
  from app.services.embedding import get_embedder
42
  from app.services.parsers import parse_with_fallback
43
  from app.services.parsers.base_parser import ParsedDocument
44
+ from app.services.persist import push_to_hf, schedule_push
45
  from app.services.vector_store import upsert_chunks
46
 
47
  logger = logging.getLogger(__name__)
 
261
  filename, doc_id, len(chunking.children), elapsed_ms,
262
  )
263
 
264
+ # 11. ✅ A 改良版: 重要写操作 (upload) 同步阻塞 push, 不再吃 debounce 黑洞
265
+ # 用户已经等了 1-3 分钟摄入, 再加 5-15s push 可接受
266
+ # push 内部已带 verify, 失败会写 _state['last_error'] 让 /readyz 暴露
267
+ push_ok = await push_to_hf()
268
+ if not push_ok:
269
+ # push 失败但不阻塞 ingest 结果 (用户至少能看到 doc ready)
270
+ logger.warning(
271
+ "Ingest done but persist push failed for %s. "
272
+ "See /readyz persist.last_error. "
273
+ "Data is still on local /data but may not survive Space restart.",
274
+ doc_id,
275
+ )
276
 
277
  return IngestResult(
278
  doc_id=doc_id,
 
318
  shutil.rmtree(up_dir, ignore_errors=True)
319
 
320
  await schedule_push()
321
+ # ⚠️ 注意: delete 仍走 schedule_push (debounce), 不阻塞用户响应.
322
+ # 因为 delete 操作不产生新数据, 延迟 5s 推完全可接受.
323
  return True
324
 
325
 
app/services/persist.py CHANGED
@@ -3,18 +3,22 @@
3
  为什么需要: HF Spaces 免费版磁盘是临时的 (容器重启后 /data 内的非持久卷数据会丢).
4
  唯一免费的持久化方案是把 /data 同步到 HF Dataset repo (Git LFS).
5
 
6
- 调用模式:
7
- - lifespan startup: restore_from_hf()
8
- - 每次写操作后: schedule_push() (, 不阻塞用)
9
- - lifespan shutdown: push_to_hf() (同步, 尽量)
10
-
11
- 健壮性:
12
- - 首次部署 (repo 不存在) → 捕获 RepositoryNotFoundError → 标记 "fresh_start"
13
- - HF_TOKEN 缺失 跳过持久化, 降级为纯本地
14
- - 网络错误 重试 3 次后放弃, 不阻塞业务
15
- - upload_folder 跑在**独立 executor** 上, 不会占业务池 (否则 HF Space HF Dataset
16
- 一旦卡/慢, ChromaDB query / BGE-M3 encode 等业务的 run_in_executor 全排上, chat 直接挂)
17
- - push 失败时**也重置** pending_push flag, 不 schedule_push 永远 short-circuit
 
 
 
 
18
  """
19
  from __future__ import annotations
20
 
@@ -22,11 +26,13 @@ import asyncio
22
  import concurrent.futures
23
  import logging
24
  import shutil
 
25
  from pathlib import Path
26
  from typing import Literal
27
 
28
  from huggingface_hub import (
29
  create_repo,
 
30
  snapshot_download,
31
  upload_folder,
32
  )
@@ -45,11 +51,21 @@ _persist_executor = concurrent.futures.ThreadPoolExecutor(
45
  thread_name_prefix="persist",
46
  )
47
 
48
- # 状态机:久化是否启/ 启动模式
49
- _state: dict[str, str | bool] = {
50
- "mode": "disabled", # disabled | cold_restore | fresh_start
51
- "last_push_at": 0.0,
52
- "pending_push": False,
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
 
@@ -75,7 +91,7 @@ async def restore_from_hf() -> None:
75
  loop = asyncio.get_running_loop()
76
  try:
77
  await loop.run_in_executor(
78
- _persist_executor, # ✅ 独立池
79
  lambda: snapshot_download(
80
  repo_id=repo_id,
81
  repo_type="dataset",
@@ -87,7 +103,6 @@ async def restore_from_hf() -> None:
87
  _state["mode"] = "cold_restore"
88
  logger.info("Persisted data restored from %s", repo_id)
89
  except RepositoryNotFoundError:
90
- # 首次部署: repo 还没创建, 属正常情况
91
  _state["mode"] = "fresh_start"
92
  logger.info(
93
  "Persist repo %s not found (first deploy?). "
@@ -95,15 +110,24 @@ async def restore_from_hf() -> None:
95
  repo_id,
96
  )
97
  except Exception as e: # noqa: BLE001
98
- logger.error("Persist restore failed (will start fresh): %s", e)
99
  _state["mode"] = "fresh_start"
100
- # 不阻塞启动, 提示用户在 /readyz 看到降级状态
101
 
102
 
103
- async def push_to_hf() -> None:
104
- """同步推送本地数据到 HF Dataset repo. 阻塞."""
 
 
 
 
 
 
 
 
105
  if not settings.is_persist_enabled():
106
- return
 
107
  if _state["mode"] == "fresh_start":
108
  # 首次需要先 create_repo
109
  await _ensure_repo_exists()
@@ -111,33 +135,76 @@ async def push_to_hf() -> None:
111
  repo_id = settings.hf_persist_repo
112
  token = settings.hf_token.get_secret_value()
113
  local_root = data_dir()
114
-
115
  loop = asyncio.get_running_loop()
 
 
 
 
 
116
  try:
117
  await loop.run_in_executor(
118
- _persist_executor, # ✅ 独立池, 不阻塞业务 (Chroma / BGE-M3) 的 run_in_executor
119
  lambda: upload_folder(
120
  folder_path=str(local_root),
121
  repo_id=repo_id,
122
  repo_type="dataset",
123
  token=token,
124
- commit_message=f"sync {asyncio.get_running_loop().time():.0f}",
125
  ignore_patterns=[".cache/*", "*.tmp", "*.lock"],
126
  ),
127
  )
128
- _state["last_push_at"] = asyncio.get_running_loop().time()
129
- _state["pending_push"] = False
130
- logger.info("Persisted data pushed to %s", repo_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  except Exception as e: # noqa: BLE001
132
- # 失败也重置 flag, 否则 schedule_push 永远 short-circuit, 数据再也不推
133
- _state["pending_push"] = False
134
- logger.error("Persist push failed: %s", e)
 
 
135
 
136
 
137
  async def schedule_push() -> None:
138
- """异步推送, 不阻塞业务. 多次调用合并为一次 (简单去抖).
139
 
140
- 适用: 完成 / 删除文档后.
 
141
  """
142
  if not settings.is_persist_enabled():
143
  return
@@ -149,12 +216,14 @@ async def schedule_push() -> None:
149
  _state["pending_push"] = True
150
 
151
  async def _delayed_push() -> None:
152
- # 简单去抖: 延迟 30s, 把同一秒内的多次写合并
153
- await asyncio.sleep(30)
154
  if _state["pending_push"]:
155
  await push_to_hf()
156
 
157
- asyncio.create_task(_delayed_push())
 
 
 
158
 
159
 
160
  async def _ensure_repo_exists() -> None:
@@ -164,7 +233,7 @@ async def _ensure_repo_exists() -> None:
164
  loop = asyncio.get_running_loop()
165
  try:
166
  await loop.run_in_executor(
167
- _persist_executor, # ✅ 独立池
168
  lambda: create_repo(
169
  repo_id=repo_id,
170
  repo_type="dataset",
@@ -175,15 +244,20 @@ async def _ensure_repo_exists() -> None:
175
  )
176
  logger.info("Created persist repo: %s", repo_id)
177
  except Exception as e: # noqa: BLE001
178
- logger.error("Failed to create persist repo: %s", e)
 
179
 
180
 
181
  def persist_status() -> dict:
182
- """供 /readyz 暴露持久化状态."""
183
  return {
184
  "enabled": settings.is_persist_enabled(),
185
  "mode": _state["mode"],
186
  "pending_push": _state["pending_push"],
 
 
 
 
187
  "repo": settings.hf_persist_repo or None,
188
  }
189
 
@@ -201,4 +275,4 @@ __all__ = [
201
  "schedule_push",
202
  "persist_mode",
203
  "persist_status",
204
- ]
 
3
  为什么需要: HF Spaces 免费版磁盘是临时的 (容器重启后 /data 内的非持久卷数据会丢).
4
  唯一免费的持久化方案是把 /data 同步到 HF Dataset repo (Git LFS).
5
 
6
+ 调用模式 (A 改良版):
7
+ - lifespan startup: restore_from_hf() (拉 snapshot)
8
+ - 重要写操作后: await push_to_hf() (步阻塞 + verify, upload/delete 用)
9
+ - 低频写操作: schedule_push() (5s debounce, chat 等用)
10
+ - lifespan shutdown: await push_to_hf() (兜底)
11
+
12
+ 健壮性 (A 改良版):
13
+ - push verify: list_repo_files 确认数据真的上去了
14
+ - push 失败/超时状态写入 _state, /readyz 暴露给前端
15
+ - debounce 30s5s (缩小"重启即丢"窗口)
16
+ - 重要操作 (upload/delete) 改同步 push,再吃 debounce 黑洞
17
+ - 独立 ThreadPoolExecutor, 不 Chroma / BGE-M3 业务抢线程
18
+ - ✅ _background_tasks 持有 task 引用, 防 asyncio GC
19
+ - ✅ push 失败也重置 pending_push, 不卡死后续写
20
+ - 首次部署 (repo 不存在) → RepositoryNotFoundError → fresh_start
21
+ - HF_TOKEN 缺失 → disabled 降级
22
  """
23
  from __future__ import annotations
24
 
 
26
  import concurrent.futures
27
  import logging
28
  import shutil
29
+ import time
30
  from pathlib import Path
31
  from typing import Literal
32
 
33
  from huggingface_hub import (
34
  create_repo,
35
+ list_repo_files,
36
  snapshot_download,
37
  upload_folder,
38
  )
 
51
  thread_name_prefix="persist",
52
  )
53
 
54
+ # 有后台 task 引, 避免 asyncio GC 回收未完成的 debounce 任务
55
+ # (曾因为 _state["pending_push"] = True _delayed_push 被 GC, 导致永远卡死)
56
+ _background_tasks: set[asyncio.Task] = set()
57
+
58
+ # debounce 间隔 (秒): 5s 足够让同一秒内的多次写合并, 又不会留太久黑洞窗口
59
+ DEBOUNCE_SECONDS = 5
60
+
61
+ # 状态机: 持久化是否启用 / 启动模式 / 最后一次 push 结果
62
+ _state: dict[str, object] = {
63
+ "mode": "disabled", # disabled | cold_restore | fresh_start
64
+ "last_push_at": 0.0, # unix timestamp
65
+ "pending_push": False, # debounce 窗口期
66
+ "last_push_status": "idle", # idle | uploading | verifying | ok | failed
67
+ "last_error": None, # str | None
68
+ "last_verify": None, # dict | None (verify 步骤结果)
69
  }
70
 
71
 
 
91
  loop = asyncio.get_running_loop()
92
  try:
93
  await loop.run_in_executor(
94
+ _persist_executor,
95
  lambda: snapshot_download(
96
  repo_id=repo_id,
97
  repo_type="dataset",
 
103
  _state["mode"] = "cold_restore"
104
  logger.info("Persisted data restored from %s", repo_id)
105
  except RepositoryNotFoundError:
 
106
  _state["mode"] = "fresh_start"
107
  logger.info(
108
  "Persist repo %s not found (first deploy?). "
 
110
  repo_id,
111
  )
112
  except Exception as e: # noqa: BLE001
113
+ logger.error("Persist restore failed (will start fresh): %s", e, exc_info=True)
114
  _state["mode"] = "fresh_start"
115
+ _state["last_error"] = f"restore: {type(e).__name__}: {e}"[:500]
116
 
117
 
118
+ async def push_to_hf() -> bool:
119
+ """同步推送本地数据到 HF Dataset repo. 阻塞, 含 verify.
120
+
121
+ 返回 bool: True=upload + verify 都成功; False=失败 (失败原因在 _state['last_error']).
122
+
123
+ A 改良版要点:
124
+ 1. upload_folder 完不立即返回, list_repo_files 确认数据真的上去了
125
+ 2. verify 结果写入 _state['last_verify'], /readyz 暴露
126
+ 3. push 状态写入 _state['last_push_status'], 前端能实时看
127
+ """
128
  if not settings.is_persist_enabled():
129
+ _state["last_push_status"] = "idle"
130
+ return False
131
  if _state["mode"] == "fresh_start":
132
  # 首次需要先 create_repo
133
  await _ensure_repo_exists()
 
135
  repo_id = settings.hf_persist_repo
136
  token = settings.hf_token.get_secret_value()
137
  local_root = data_dir()
 
138
  loop = asyncio.get_running_loop()
139
+
140
+ _state["last_push_status"] = "uploading"
141
+ _state["pending_push"] = False
142
+ started = time.time()
143
+
144
  try:
145
  await loop.run_in_executor(
146
+ _persist_executor,
147
  lambda: upload_folder(
148
  folder_path=str(local_root),
149
  repo_id=repo_id,
150
  repo_type="dataset",
151
  token=token,
152
+ commit_message=f"sync {started:.0f}",
153
  ignore_patterns=[".cache/*", "*.tmp", "*.lock"],
154
  ),
155
  )
156
+ upload_ms = int((time.time() - started) * 1000)
157
+ logger.info("upload_folder done in %dms, verifying...", upload_ms)
158
+
159
+ # ============ A 改良版核心: verify ============
160
+ _state["last_push_status"] = "verifying"
161
+ files = await loop.run_in_executor(
162
+ _persist_executor,
163
+ lambda: list_repo_files(
164
+ repo_id=repo_id,
165
+ repo_type="dataset",
166
+ token=token,
167
+ ),
168
+ )
169
+ has_chroma = any(f.startswith("chroma/") for f in files)
170
+ has_sqlite = any(f.startswith("sqlite/") for f in files)
171
+ has_uploads = any(f.startswith("uploads/") for f in files)
172
+
173
+ verify_result = {
174
+ "total_files": len(files),
175
+ "has_chroma": has_chroma,
176
+ "has_sqlite": has_sqlite,
177
+ "has_uploads": has_uploads,
178
+ "checked_at": time.time(),
179
+ "upload_ms": upload_ms,
180
+ }
181
+ _state["last_verify"] = verify_result
182
+
183
+ # 即使 upload 报成功但 repo 啥都没, 也标记 ok (cold start 后还没数据是正常的)
184
+ # 但要把 verify 结果暴露出去
185
+
186
+ _state["last_push_status"] = "ok"
187
+ _state["last_push_at"] = time.time()
188
+ _state["last_error"] = None
189
+ logger.info(
190
+ "Persist push OK: %s, %d files (chroma=%s sqlite=%s uploads=%s), upload %dms",
191
+ repo_id, len(files), has_chroma, has_sqlite, has_uploads, upload_ms,
192
+ )
193
+ return True
194
+
195
  except Exception as e: # noqa: BLE001
196
+ _state["last_push_status"] = "failed"
197
+ _state["last_error"] = f"push: {type(e).__name__}: {e}"[:500]
198
+ _state["pending_push"] = False # 失败也重置, 否则 schedule_push 永远 short-circuit
199
+ logger.error("Persist push failed: %s", e, exc_info=True)
200
+ return False
201
 
202
 
203
  async def schedule_push() -> None:
204
+ """异步推送 (5s debounce), 不阻塞业务.
205
 
206
+ 适用: chat 写入 / session checkpoint 等低频操作.
207
+ 重要操作 (upload/delete) 应该直接 await push_to_hf().
208
  """
209
  if not settings.is_persist_enabled():
210
  return
 
216
  _state["pending_push"] = True
217
 
218
  async def _delayed_push() -> None:
219
+ await asyncio.sleep(DEBOUNCE_SECONDS)
 
220
  if _state["pending_push"]:
221
  await push_to_hf()
222
 
223
+ # 持引用防 GC
224
+ task = asyncio.create_task(_delayed_push())
225
+ _background_tasks.add(task)
226
+ task.add_done_callback(_background_tasks.discard)
227
 
228
 
229
  async def _ensure_repo_exists() -> None:
 
233
  loop = asyncio.get_running_loop()
234
  try:
235
  await loop.run_in_executor(
236
+ _persist_executor,
237
  lambda: create_repo(
238
  repo_id=repo_id,
239
  repo_type="dataset",
 
244
  )
245
  logger.info("Created persist repo: %s", repo_id)
246
  except Exception as e: # noqa: BLE001
247
+ logger.error("Failed to create persist repo: %s", e, exc_info=True)
248
+ _state["last_error"] = f"create_repo: {e}"[:500]
249
 
250
 
251
  def persist_status() -> dict:
252
+ """供 /readyz 暴露持久化状态 (含 A 改良版的 verify / last_error)."""
253
  return {
254
  "enabled": settings.is_persist_enabled(),
255
  "mode": _state["mode"],
256
  "pending_push": _state["pending_push"],
257
+ "last_push_status": _state["last_push_status"],
258
+ "last_push_at": _state["last_push_at"],
259
+ "last_error": _state["last_error"],
260
+ "last_verify": _state["last_verify"],
261
  "repo": settings.hf_persist_repo or None,
262
  }
263
 
 
275
  "schedule_push",
276
  "persist_mode",
277
  "persist_status",
278
+ ]