a3216 commited on
Commit
fb0cf86
·
verified ·
1 Parent(s): f1d161b

sync from GitHub 7b6cdc8: fix(v5后端): 修复管理面板加载不出数据-HF Space重建后从Hub恢复manifest

Browse files
app/api/admin_data.py CHANGED
@@ -388,6 +388,17 @@ async def list_backups_v5(
388
  返回字段:set_id, user_id, alias, total_size, blob_count,
389
  created_at, is_latest, prev_set_id。
390
  """
 
 
 
 
 
 
 
 
 
 
 
391
  where = ""
392
  args: list = []
393
  if q:
 
388
  返回字段:set_id, user_id, alias, total_size, blob_count,
389
  created_at, is_latest, prev_set_id。
390
  """
391
+ # 本地 DB 为空时尝试从 Hub 同步(HF Space 重建后 DB 丢失)
392
+ with get_conn() as conn:
393
+ cnt = conn.execute("SELECT COUNT(*) AS c FROM backup_sets").fetchone()["c"]
394
+ if cnt == 0:
395
+ try:
396
+ import asyncio
397
+ from ..services import backup_v5_store
398
+ asyncio.create_task(backup_v5_store._sync_manifests_background())
399
+ except Exception:
400
+ pass
401
+
402
  where = ""
403
  args: list = []
404
  if q:
app/api/user_backups_v5.py CHANGED
@@ -59,6 +59,9 @@ async def list_sets(request: Request):
59
  offset = int(request.query_params.get("offset") or "0")
60
  except Exception:
61
  limit, offset = 20, 0
 
 
 
62
  items = backup_v5_store.list_sets(user_id, limit=limit, offset=offset)
63
  return ok_with_cors({"items": items, "count": len(items), "total": len(items)})
64
 
 
59
  offset = int(request.query_params.get("offset") or "0")
60
  except Exception:
61
  limit, offset = 20, 0
62
+ # 本地 DB 为空时触发 Hub 同步(HF Space 重建后 DB 丢失,Hub 上 manifest 仍在)
63
+ if backup_v5_store.count_sets(user_id) == 0:
64
+ asyncio.create_task(backup_v5_store._sync_manifests_background())
65
  items = backup_v5_store.list_sets(user_id, limit=limit, offset=offset)
66
  return ok_with_cors({"items": items, "count": len(items), "total": len(items)})
67
 
app/main.py CHANGED
@@ -147,6 +147,9 @@ async def lifespan(app: FastAPI):
147
  # 启动 v5 备份 GC 任务(清理 refs=0 的 blob,每小时一次)
148
  from .services import backup_v5_store
149
  backup_v5_store.start_gc_task()
 
 
 
150
  # 启动用户文件 Hub 定时同步任务
151
  s = get_settings()
152
  if is_hub_enabled():
 
147
  # 启动 v5 备份 GC 任务(清理 refs=0 的 blob,每小时一次)
148
  from .services import backup_v5_store
149
  backup_v5_store.start_gc_task()
150
+ # v5 备份:从 Hub 同步 manifest 元数据回本地 DB(HF Space 重建后本地 DB 丢失,
151
+ # 但 Hub 上 backup_sets_v5/<user_id>/<set_id>.json 仍存在,需恢复否则管理面板和列表为空)
152
+ asyncio.create_task(backup_v5_store._sync_manifests_background())
153
  # 启动用户文件 Hub 定时同步任务
154
  s = get_settings()
155
  if is_hub_enabled():
app/services/backup_v5_store.py CHANGED
@@ -24,6 +24,7 @@ from ..hf_storage import (
24
  delete_from_hub,
25
  download_bytes,
26
  is_hub_enabled,
 
27
  push_to_hub,
28
  upload_bytes,
29
  )
@@ -323,6 +324,16 @@ def batch_check_blobs(prev_set_id: Optional[str], paths: list[dict]) -> dict:
323
 
324
  # ===== 备份集管理 =====
325
 
 
 
 
 
 
 
 
 
 
 
326
  def list_sets(user_id: str, limit: int = 20, offset: int = 0) -> list[dict]:
327
  with get_conn() as conn:
328
  rows = conn.execute(
@@ -788,3 +799,159 @@ async def _gc_loop() -> None:
788
  await asyncio.sleep(GC_INTERVAL_SEC)
789
  except asyncio.CancelledError:
790
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  delete_from_hub,
25
  download_bytes,
26
  is_hub_enabled,
27
+ list_hub_keys,
28
  push_to_hub,
29
  upload_bytes,
30
  )
 
324
 
325
  # ===== 备份集管理 =====
326
 
327
+ def count_sets(user_id: str) -> int:
328
+ """统计指定用户的备份集数量(用于判断是否需要触发 Hub 同步)。"""
329
+ with get_conn() as conn:
330
+ row = conn.execute(
331
+ "SELECT COUNT(*) AS c FROM backup_sets WHERE user_id = ?",
332
+ (user_id,),
333
+ ).fetchone()
334
+ return int(row["c"]) if row else 0
335
+
336
+
337
  def list_sets(user_id: str, limit: int = 20, offset: int = 0) -> list[dict]:
338
  with get_conn() as conn:
339
  rows = conn.execute(
 
799
  await asyncio.sleep(GC_INTERVAL_SEC)
800
  except asyncio.CancelledError:
801
  break
802
+
803
+
804
+ def sync_manifests_from_hub() -> dict:
805
+ """从 Hub 同步 manifest 元数据回本地 backup_sets 表。
806
+
807
+ HF Space 重建后本地 DB(ephemeral)被清空,但 Hub 上 backup_sets_v5/<user_id>/<set_id>.json
808
+ 仍然存在。此函数列出 Hub 上所有 manifest JSON,对本地 DB 缺失的 set 补录元数据,
809
+ 并恢复 blob refs 计数。
810
+
811
+ 幂等:已存在的 set 跳过,不重复插入。
812
+
813
+ 返回 {"synced": N, "skipped": M, "failed": K}
814
+ """
815
+ if not is_hub_enabled():
816
+ logger.warning("[v5_sync] hub disabled, skip sync")
817
+ return {"synced": 0, "skipped": 0, "failed": 0}
818
+
819
+ # 列出 Hub 上所有 manifest key(格式:<user_id>/<set_id>.json)
820
+ try:
821
+ hub_keys = list_hub_keys(NS_MANIFESTS)
822
+ except Exception as e:
823
+ logger.warning("[v5_sync] list_hub_keys failed: %s", e)
824
+ return {"synced": 0, "skipped": 0, "failed": 0}
825
+
826
+ if not hub_keys:
827
+ logger.info("[v5_sync] no manifests on hub")
828
+ return {"synced": 0, "skipped": 0, "failed": 0}
829
+
830
+ synced = 0
831
+ skipped = 0
832
+ failed = 0
833
+
834
+ for sub_key in hub_keys:
835
+ # sub_key 格式:<user_id>/<set_id>.json
836
+ parts = sub_key.rsplit("/", 1)
837
+ if len(parts) != 2 or not parts[1].endswith(".json"):
838
+ continue
839
+ user_id = parts[0]
840
+ set_id = parts[1][:-5] # 去掉 .json
841
+ if not user_id or not set_id:
842
+ continue
843
+
844
+ # 检查本地 DB 是否已有此 set
845
+ try:
846
+ with get_conn() as conn:
847
+ row = conn.execute(
848
+ "SELECT set_id FROM backup_sets WHERE set_id = ?",
849
+ (set_id,),
850
+ ).fetchone()
851
+ if row:
852
+ skipped += 1
853
+ continue
854
+ except Exception:
855
+ pass
856
+
857
+ # 从 Hub 下载 manifest JSON
858
+ try:
859
+ data = download_bytes(NS_MANIFESTS, sub_key)
860
+ if not data:
861
+ failed += 1
862
+ continue
863
+ manifest = json.loads(data.decode("utf-8"))
864
+ if not isinstance(manifest, dict):
865
+ failed += 1
866
+ continue
867
+ if int(manifest.get("version") or 0) != 5:
868
+ failed += 1
869
+ continue
870
+
871
+ # 写 manifest 到本地缓存(与 _write_manifest_to_local 一致)
872
+ _write_manifest_to_local(user_id, set_id, manifest)
873
+
874
+ # 解析 manifest 字段,写入 backup_sets 表
875
+ blobs = manifest.get("blobs")
876
+ if not isinstance(blobs, list):
877
+ blobs = []
878
+ keys = [str(b.get("key") or "") for b in blobs if isinstance(b, dict) and b.get("key")]
879
+ total_size = sum(int(b.get("size") or 0) for b in blobs if isinstance(b, dict))
880
+ blob_count = len(blobs)
881
+ checksum = manifest.get("checksum") or hashlib.sha256(
882
+ json.dumps({k: v for k, v in manifest.items() if k != "checksum"},
883
+ sort_keys=True, ensure_ascii=False).encode("utf-8")
884
+ ).hexdigest()
885
+ created_at = int(manifest.get("exportedAt") or _now())
886
+ prev_set_id = str(manifest.get("prevSetId") or "")
887
+ alias = str(manifest.get("alias") or "")[:200]
888
+
889
+ with get_conn() as conn:
890
+ # INSERT OR IGNORE 避免并发同步重复插入
891
+ conn.execute(
892
+ "INSERT OR IGNORE INTO backup_sets(set_id, user_id, manifest, manifest_sha256, "
893
+ "total_size, blob_count, created_at, is_latest, prev_set_id, alias) "
894
+ "VALUES(?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
895
+ (
896
+ set_id, user_id, json.dumps(manifest, ensure_ascii=False), checksum,
897
+ total_size, blob_count, created_at, prev_set_id, alias,
898
+ ),
899
+ )
900
+ # 恢复 blob refs(每个 set 引用的 blob refs +1)
901
+ for k in keys:
902
+ # 先确保 blob 记录存在(可能本地 DB 也丢了 blob 元数据)
903
+ existing = conn.execute(
904
+ "SELECT key FROM backup_blobs WHERE key = ?", (k,)
905
+ ).fetchone()
906
+ if existing:
907
+ conn.execute(
908
+ "UPDATE backup_blobs SET refs = refs + 1 WHERE key = ?",
909
+ (k,),
910
+ )
911
+ else:
912
+ # blob 元数据丢失,从 manifest 补录(size 来自 manifest)
913
+ blob_info = next((b for b in blobs if isinstance(b, dict) and b.get("key") == k), {})
914
+ conn.execute(
915
+ "INSERT OR IGNORE INTO backup_blobs(key, size, mime, refs, first_uploaded_at, first_uploaded_by) "
916
+ "VALUES(?, ?, ?, 1, ?, ?)",
917
+ (k, int(blob_info.get("size") or 0), str(blob_info.get("type") or ""),
918
+ created_at, user_id),
919
+ )
920
+
921
+ # 标记 is_latest:每个用户最新的 set(按 created_at 降序第一个)
922
+ synced += 1
923
+ except Exception as e:
924
+ logger.warning("[v5_sync] sync set=%s failed: %s", set_id[:8], e)
925
+ failed += 1
926
+
927
+ # 修正每个用户的 is_latest 标记:最新 set(按 created_at 降序)置 1,其余置 0
928
+ try:
929
+ with get_conn() as conn:
930
+ users = conn.execute("SELECT DISTINCT user_id FROM backup_sets").fetchall()
931
+ for u in users:
932
+ uid = str(u["user_id"])
933
+ latest = conn.execute(
934
+ "SELECT set_id FROM backup_sets WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
935
+ (uid,),
936
+ ).fetchone()
937
+ if latest:
938
+ conn.execute(
939
+ "UPDATE backup_sets SET is_latest = CASE WHEN set_id = ? THEN 1 ELSE 0 END WHERE user_id = ?",
940
+ (str(latest["set_id"]), uid),
941
+ )
942
+ except Exception as e:
943
+ logger.warning("[v5_sync] fix is_latest failed: %s", e)
944
+
945
+ logger.info("[v5_sync] done: synced=%d skipped=%d failed=%d", synced, skipped, failed)
946
+ return {"synced": synced, "skipped": skipped, "failed": failed}
947
+
948
+
949
+ async def _sync_manifests_background() -> None:
950
+ """后台同步 manifests(在 startup 中异步调用,不阻塞启动)。"""
951
+ try:
952
+ # 放到线程池执行(list_hub_keys 和 download_bytes 是同步 IO)
953
+ result = await asyncio.to_thread(sync_manifests_from_hub)
954
+ if result["synced"] > 0:
955
+ logger.info("[v5_sync] background sync recovered %d sets", result["synced"])
956
+ except Exception as e:
957
+ logger.warning("[v5_sync] background sync failed: %s", e)