Add database backups and automatic restore

Daemon job "backup" (online copy, quick_check, rotation), restore of the
newest valid copy when the database cannot be opened, change journal reset
after restore, last_restore in /health, docs and tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-21 08:55:42 +03:00
1 parent c99665542a
commit 02f7b49e12
10 files changed
+294 -21

No files matched your search

+51
View File
@@ -1,6 +1,8 @@
import datetime
import glob
import json
import os
import sqlite3
import sys
import pytest
@@ -15,6 +17,8 @@ import db
def files(tmp_path, monkeypatch):
for attr, name in (("DB_FILE", "ripe.db"), ("DATA_FILE", "data.json"), ("FQDN_DATA_FILE", "fqdn_data.json")):
monkeypatch.setattr(cc, attr, str(tmp_path / name))
monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups"))
monkeypatch.setattr(cc, "RESTORE_FILE", str(tmp_path / "last_restore.json"))
return tmp_path
@@ -76,3 +80,50 @@ def test_change_journal(files):
assert db.get_changes(conn, {"asn"}, cursor=0) is None
assert db.get_changes(conn, {"asn"}, cursor=c3 + 1) is None
assert db.get_changes(conn, {"asn"}, cursor=c3) == (set(), set(), c3)
def test_backup_rotation_and_integrity(files):
now = datetime.datetime.now()
with db.session() as conn:
with db.transaction(conn):
db.merge_source(conn, "asn", "1", {"a"}, now, 90)
for minutes in range(3):
db.backup_database(conn, now + datetime.timedelta(minutes=minutes), keep=2)
# Ротация оставляет две самые свежие копии; копия самодостаточна и содержит данные
backups = db.list_backups(cc.BACKUP_DIR)
assert len(backups) == 2 and not glob.glob(os.path.join(cc.BACKUP_DIR, "*.tmp"))
assert backups[0].endswith(f"{(now + datetime.timedelta(minutes=2)).astimezone(datetime.timezone.utc):%Y%m%dT%H%M%SZ}.db")
assert sqlite3.connect(backups[0]).execute("SELECT COUNT(*) FROM addresses").fetchone()[0] == 1
def test_restore_from_backup(files):
now = datetime.datetime.now()
with db.session() as conn:
with db.transaction(conn):
db.merge_source(conn, "asn", "1", {"a", "b"}, now, 90)
old_cursor = db.get_changes(conn, {"asn"}, cursor=0)[2]
db.backup_database(conn, now, keep=3)
# «Свежая» копия с мусором пропускается; живая база портится
(files / "backups" / "ripe-99999999T999999Z.db").write_bytes(b"garbage" * 100)
(files / "ripe.db").write_bytes(b"garbage" * 1000)
with db.session() as conn:
assert sorted(db.get_values(conn, "asn")) == ["a", "b"]
# Курсор, выданный до отката, недействителен (клиент сделает полную выгрузку); новый работает
assert db.get_changes(conn, {"asn"}, cursor=old_cursor) is None
head = db.journal_head(conn)
assert db.get_changes(conn, {"asn"}, cursor=head) == (set(), set(), head)
with db.transaction(conn):
db.merge_source(conn, "asn", "1", {"a", "b", "c"}, now, 90)
assert db.get_changes(conn, {"asn"}, cursor=head)[:2] == ({"c"}, set())
assert len(glob.glob(str(files / "ripe.db.corrupt-*"))) == 1
assert json.loads((files / "last_restore.json").read_text())["backup"].endswith(".db")
# Без исправных копий - прежнее поведение: ошибка хранилища, база в карантине
for backup in db.list_backups(cc.BACKUP_DIR):
os.unlink(backup)
(files / "ripe.db").write_bytes(b"garbage" * 1000)
with pytest.raises(db.StorageError):
db.connect()