salgram / hf_backup.py
ChessVania's picture
Deploy SalGram (Docker)
484ec23 verified
Raw
History Blame Contribute Delete
7.67 kB
"""Резервное копирование данных SalGram в приватный репозиторий Hugging Face.
Зачем: на бесплатном HF Space диск эфемерный — при пересборке/перезапуске/
«засыпании» Space всё в контейнере пропадает. Этот модуль раз в
HF_BACKUP_INTERVAL секунд снимает консистентную копию БД и выгружает её
(вместе с ключом шифрования и медиафайлами) в ПРИВАТНЫЙ dataset-репозиторий,
а при старте контейнера восстанавливает оттуда. Так код остаётся в публичном
Space, а данные лежат только в приватном репо — их видишь и качаешь только ты.
Включается только если заданы переменные окружения:
HF_BACKUP_REPO — id приватного репо, напр. "username/salgram-data"
HF_TOKEN — токен с правом записи в этот репо
HF_BACKUP_INTERVAL — (необяз.) период в секундах, по умолчанию 1200 (20 мин)
Без них модуль ничего не делает — локальная разработка и Amvera не затронуты.
"""
import json
import os
import shutil
import sqlite3
import tempfile
import threading
import time
import traceback
REPO_ENV = "HF_BACKUP_REPO"
TOKEN_ENV = "HF_TOKEN"
INTERVAL_ENV = "HF_BACKUP_INTERVAL"
DEFAULT_INTERVAL = 1200 # 20 минут
REPO_TYPE = "dataset"
DB_NAME = "salgram.db"
EXTRA_FILES = ("secret.key", "vapid.json") # без них восстановленную БД не расшифровать
MEDIA_DIRS = ("avatars", "photos", "files", "wallpapers", "gifs", "audio")
_INDEX = ".hf_uploaded.json" # локальный список уже выгруженных файлов (чтобы не слать повторно)
def _config():
"""(repo, token, interval) если бэкап настроен, иначе None."""
repo = os.environ.get(REPO_ENV)
token = os.environ.get(TOKEN_ENV)
if not repo or not token:
return None
try:
interval = int(os.environ.get(INTERVAL_ENV, DEFAULT_INTERVAL))
except ValueError:
interval = DEFAULT_INTERVAL
return repo, token, max(60, interval)
def _log(msg):
print(f"[hf_backup] {msg}", flush=True)
def _snapshot_db(data_dir, dst_path):
"""Консистентная копия БД через online-backup API SQLite (безопасно при WAL)."""
src = sqlite3.connect(os.path.join(data_dir, DB_NAME), timeout=30)
try:
dst = sqlite3.connect(dst_path)
try:
with dst:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
def restore_on_boot(data_dir):
"""Если локальной БД нет — скачать последнюю копию из приватного репо.
Вызывать ДО _load_key() и init_db(): восстановленные ключ и БД нужны им.
"""
cfg = _config()
if not cfg:
return
repo, token, _ = cfg
if os.path.exists(os.path.join(data_dir, DB_NAME)):
_log("локальная БД уже на месте — восстановление пропущено")
return
try:
from huggingface_hub import snapshot_download
snap = snapshot_download(repo_id=repo, repo_type=REPO_TYPE, token=token)
except Exception as e: # репо ещё нет / пустой / нет сети — стартуем с чистой БД
_log(f"копий пока нет или репо недоступен ({e!r}) — старт с чистой БД")
return
count = 0
for root, _dirs, files in os.walk(snap):
for name in files:
if name.startswith("."):
continue # служебные .gitattributes / .cache
rel = os.path.relpath(os.path.join(root, name), snap)
target = os.path.join(data_dir, rel)
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
# snapshot_download кладёт симлинки на кеш — копируем именно содержимое.
shutil.copyfile(os.path.realpath(os.path.join(root, name)), target)
count += 1
_log(f"восстановлено файлов из бэкапа: {count}")
def _remote_files(repo, token):
"""Список файлов, уже лежащих в репо (для первичного заполнения индекса)."""
try:
from huggingface_hub import HfApi
return set(HfApi(token=token).list_repo_files(repo, repo_type=REPO_TYPE))
except Exception:
return set()
def backup_now(data_dir):
"""Один цикл бэкапа: снимок БД + новые медиа/ключи -> приватный репо."""
cfg = _config()
if not cfg:
return
repo, token, _ = cfg
from huggingface_hub import CommitOperationAdd, HfApi
api = HfApi(token=token)
api.create_repo(repo, repo_type=REPO_TYPE, private=True, exist_ok=True)
index_path = os.path.join(data_dir, _INDEX)
if os.path.exists(index_path):
with open(index_path) as f:
uploaded = set(json.load(f))
else: # первый бэкап в этом контейнере — берём, что уже есть в репо
uploaded = _remote_files(repo, token)
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, DB_NAME)
_snapshot_db(data_dir, snap)
ops = [CommitOperationAdd(DB_NAME, snap)] # БД меняется всегда — шлём каждый раз
added = []
for name in EXTRA_FILES:
path = os.path.join(data_dir, name)
if os.path.exists(path) and name not in uploaded:
ops.append(CommitOperationAdd(name, path))
added.append(name)
for d in MEDIA_DIRS:
ddir = os.path.join(data_dir, d)
if not os.path.isdir(ddir):
continue
for name in sorted(os.listdir(ddir)):
rel = f"{d}/{name}"
path = os.path.join(ddir, name)
if os.path.isfile(path) and rel not in uploaded:
ops.append(CommitOperationAdd(rel, path))
added.append(rel)
api.create_commit(repo_id=repo, repo_type=REPO_TYPE, operations=ops,
commit_message="SalGram backup")
uploaded.update(added)
with open(index_path, "w") as f:
json.dump(sorted(uploaded), f)
return len(added)
def start(data_dir):
"""Запустить фоновый поток периодического бэкапа (no-op, если не настроено)."""
cfg = _config()
if not cfg:
return
_, _, interval = cfg
def loop():
while True:
time.sleep(interval)
try:
n = backup_now(data_dir)
_log(f"бэкап выполнен (+{n} новых файлов)")
except Exception:
_log("ошибка бэкапа:\n" + traceback.format_exc())
threading.Thread(target=loop, name="hf-backup", daemon=True).start()
_log(f"периодический бэкап включён: каждые {interval} с -> {os.environ[REPO_ENV]}")