80 lines
2.8 KiB
Python
80 lines
2.8 KiB
Python
|
|
"""Автоматическая резервная копия PostgreSQL не реже раза в сутки.
|
|||
|
|
|
|||
|
|
Процесс живёт отдельным Compose-сервисом. Он делает копию сразу, если свежей
|
|||
|
|
нет, затем ориентируется на фактическое время последнего успешного файла.
|
|||
|
|
После ошибки повторяет попытку чаще, но не удаляет существующие копии.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|||
|
|
sys.path.insert(0, str(ROOT))
|
|||
|
|
|
|||
|
|
from app.admin.backup import BackupError, DIR, create, listing # noqa: E402
|
|||
|
|
from app.config import get_settings # noqa: E402
|
|||
|
|
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format="%(asctime)s %(levelname)s backup-scheduler %(message)s",
|
|||
|
|
)
|
|||
|
|
log = logging.getLogger("backup-scheduler")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def seconds_until_due(now: datetime, interval: int) -> float:
|
|||
|
|
copies = listing()
|
|||
|
|
if not copies:
|
|||
|
|
return 0.0
|
|||
|
|
age = (now - copies[0]["at"]).total_seconds()
|
|||
|
|
return max(0.0, interval - age)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def prune(keep: int) -> int:
|
|||
|
|
old = listing()[keep:]
|
|||
|
|
deleted = 0
|
|||
|
|
for item in old:
|
|||
|
|
try:
|
|||
|
|
(DIR / item["name"]).unlink(missing_ok=True)
|
|||
|
|
deleted += 1
|
|||
|
|
except OSError as exc:
|
|||
|
|
# A locked/readonly old file must not take down the daily scheduler.
|
|||
|
|
log.error("не удалось удалить старую копию %s: %s", item["name"], exc)
|
|||
|
|
return deleted
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_forever() -> None:
|
|||
|
|
settings = get_settings()
|
|||
|
|
interval = max(60, settings.backup_interval_seconds)
|
|||
|
|
retry = max(10, min(settings.backup_retry_seconds, interval))
|
|||
|
|
keep = max(1, settings.backup_keep)
|
|||
|
|
log.info("запущен: интервал=%s с, хранить=%s", interval, keep)
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
wait = seconds_until_due(datetime.now(timezone.utc), interval)
|
|||
|
|
except BackupError as exc:
|
|||
|
|
log.error("не удалось проверить резервные копии: %s; повтор через %s с", exc, retry)
|
|||
|
|
time.sleep(retry)
|
|||
|
|
continue
|
|||
|
|
if wait > 0:
|
|||
|
|
time.sleep(wait)
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
made = create()
|
|||
|
|
deleted = prune(keep)
|
|||
|
|
log.info("готово: %s (%s байт), удалено старых=%s",
|
|||
|
|
made["name"], made["size_bytes"], deleted)
|
|||
|
|
except BackupError as exc:
|
|||
|
|
log.error("цикл резервного копирования не завершён: %s; повтор через %s с", exc, retry)
|
|||
|
|
time.sleep(retry)
|
|||
|
|
continue
|
|||
|
|
time.sleep(interval)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
run_forever()
|