Spaces:
Running
Running
sync from GitHub 7b6cdc8: fix(v5后端): 修复管理面板加载不出数据-HF Space重建后从Hub恢复manifest
Browse files- app/api/admin_data.py +11 -0
- app/api/user_backups_v5.py +3 -0
- app/main.py +3 -0
- app/services/backup_v5_store.py +167 -0
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)
|