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

+2
View File
@@ -15,3 +15,5 @@ status.json
*.migrated-* *.migrated-*
collect_request.json collect_request.json
graphify-out/ graphify-out/
backups/
last_restore.json
+2
View File
@@ -14,3 +14,5 @@ collect_request.json
graphify-out/ graphify-out/
data.json data.json
fqdn_data.json fqdn_data.json
backups/
last_restore.json
+16 -6
View File
@@ -45,6 +45,7 @@ The system consists of **two independent processes**: the collector daemon (`col
} }
``` ```
`ttl_days` - how long an address is kept after it was last seen (default `90`, `0` = keep forever). `ttl_days` - how long an address is kept after it was last seen (default `90`, `0` = keep forever).
Optional `backup_keep` - how many database backups to keep (default `7`); the backup cron is `schedule.backup` (default `30 4 * * *`), see section 9.
Optional `changes_retention_days` - how long the change journal for `/addresses/diff` is kept (default `30`, `0` = forever). Optional `changes_retention_days` - how long the change journal for `/addresses/diff` is kept (default `30`, `0` = forever).
5. **Tests** (optional) run in a container, not in the local `venv`: 5. **Tests** (optional) run in a container, not in the local `venv`:
@@ -358,7 +359,7 @@ Body:
"cron": "*/15 * * * *" "cron": "*/15 * * * *"
} }
``` ```
*Note: `type` can be `asn` or `fqdn`. The change is written to `config.json`; the collector daemon applies it within 30 seconds (`applied_within_seconds` in the response). An invalid cron expression is rejected by the API (400) and ignored by the daemon.* *Note: `type` can be `asn`, `fqdn` or `backup` (database backup, see section 9). The change is written to `config.json`; the collector daemon applies it within 30 seconds (`applied_within_seconds` in the response). An invalid cron expression is rejected by the API (400) and ignored by the daemon.*
### Endpoints: Manage ASNs and FQDNs ### Endpoints: Manage ASNs and FQDNs
The lists of monitored sources are stored in `config.json` and can be managed through the API. Reading is open; changing requires the `X-API-Key` header (same token as `POST /schedule`). The lists of monitored sources are stored in `config.json` and can be managed through the API. Reading is open; changing requires the `X-API-Key` header (same token as `POST /schedule`).
@@ -394,7 +395,7 @@ Body is optional: `type` is `asn`, `fqdn` or `all` (default). The API does not c
### Endpoint: Health ### Endpoint: Health
**GET** `/health` **GET** `/health`
Reports the state of the collector daemon (read from `status.json`): `collector_alive`, the cron / `running` / last run / `last_finished` / last error / next run of each job, and the number of stored addresses. The daemon writes a heartbeat every 30 seconds; `collector_alive` is `false` if it is older than 120 seconds or the daemon never ran. `status` is `ok` only if the daemon is alive and no job failed in its last run; otherwise `degraded` (HTTP code is still 200). Reports the state of the collector daemon (read from `status.json`): `collector_alive`, the cron / `running` / last run / `last_finished` / last error / next run of each job, the number of stored addresses and `last_restore` (`null`, or the record of the last automatic restore of the database from a backup, see section 9). The daemon writes a heartbeat every 30 seconds; `collector_alive` is `false` if it is older than 120 seconds or the daemon never ran. `status` is `ok` only if the daemon is alive and no job failed in its last run; otherwise `degraded` (HTTP code is still 200).
--- ---
@@ -433,7 +434,7 @@ When the collector runs (whether manually or via schedule):
### Scheduler Logic ### Scheduler Logic
`collector_daemon.py` uses `APScheduler` (`BlockingScheduler`) in its own process; the API has no scheduler. `collector_daemon.py` uses `APScheduler` (`BlockingScheduler`) in its own process; the API has no scheduler.
1. **Startup**: the daemon takes an exclusive lock (`collector.daemon.lock`, single instance), loads the `schedule` block from `config.json` and creates two independent jobs (`asn_job`, `fqdn_job`; defaults `0 2 * * *` and `0 3 * * *`). Overlapping runs of the same job are not allowed. 1. **Startup**: the daemon takes an exclusive lock (`collector.daemon.lock`, single instance), loads the `schedule` block from `config.json` and creates three independent jobs (`asn_job`, `fqdn_job`, `backup_job`; defaults `0 2 * * *`, `0 3 * * *` and `30 4 * * *`). Overlapping runs of the same job are not allowed.
2. **Runtime updates (POST /schedule)**: the API validates the cron expression and writes `config.json` atomically. Every 30 seconds the daemon compares the `schedule` block with the active one and reschedules the changed job (`reschedule_job`). An invalid cron expression is logged and ignored, the previous schedule stays. 2. **Runtime updates (POST /schedule)**: the API validates the cron expression and writes `config.json` atomically. Every 30 seconds the daemon compares the `schedule` block with the active one and reschedules the changed job (`reschedule_job`). An invalid cron expression is logged and ignored, the previous schedule stays.
3. **Status**: at the start and end of every run and every 30 seconds the daemon atomically writes `status.json` (jobs state + `updated_at` heartbeat); `GET /health` reads it. 3. **Status**: at the start and end of every run and every 30 seconds the daemon atomically writes `status.json` (jobs state + `updated_at` heartbeat); `GET /health` reads it.
**Manual runs**: every 5 seconds the daemon checks `collect_request.json` (written by `POST /collect`) and starts the requested collections as one-off jobs; one run per type at a time. **Manual runs**: every 5 seconds the daemon checks `collect_request.json` (written by `POST /collect`) and starts the requested collections as one-off jobs; one run per type at a time.
@@ -443,7 +444,7 @@ When the collector runs (whether manually or via schedule):
## 8. Application Setup: Docker Compose ## 8. Application Setup: Docker Compose
One image (`Dockerfile`), two services: `api` (uvicorn) and `collector` (`collector_daemon.py`). They share the named volume `ripe_data` mounted at `/data` (`RIPE_DATA_DIR`), which holds `ripe.db` (with `-wal`/`-shm`), `config.json`, `status.json` and lock files. Containers run as non-root (uid 10001) with a read-only root filesystem, dropped capabilities and rotated logs. Both have healthchecks (`healthcheck.py`). One image (`Dockerfile`), two services: `api` (uvicorn) and `collector` (`collector_daemon.py`). They share the named volume `ripe_data` mounted at `/data` (`RIPE_DATA_DIR`), which holds `ripe.db` (with `-wal`/`-shm`), `backups/` (database copies, see section 9), `config.json`, `status.json` and lock files. Containers run as non-root (uid 10001) with a read-only root filesystem, dropped capabilities and rotated logs. Both have healthchecks (`healthcheck.py`).
### Start ### Start
```bash ```bash
@@ -517,5 +518,14 @@ Automatic: on the first start of any process (API, collector or CLI) `data.json`
### Rollback to the JSON version ### Rollback to the JSON version
Stop both services, rename the `*.migrated-*` files back to `data.json` / `fqdn_data.json`, remove `ripe.db*`, start the previous version of the code. Addresses collected after the migration are lost in that case. Stop both services, rename the `*.migrated-*` files back to `data.json` / `fqdn_data.json`, remove `ripe.db*`, start the previous version of the code. Addresses collected after the migration are lost in that case.
### Backup ### Backup and automatic restore
Copy the database consistently with `sqlite3 ripe.db ".backup ripe.db.bak"` (do not copy `ripe.db` alone while the services are running - part of the data may still be in the `-wal` file). **Backup job.** The collector daemon runs the job `backup` (default `30 4 * * *`, change it with `POST /schedule` and `"type": "backup"` or in `schedule.backup`). It makes an online copy of `ripe.db` (safe while the services run), checks the live database and the copy with `PRAGMA quick_check`, and keeps the last `backup_keep` copies (default 7; older ones are deleted only after a new copy succeeded). If the live database fails the check or the copy is bad, no copy is written, the older copies stay, and `GET /health` shows `degraded` (`jobs.backup.last_error`).
Copies are named `ripe-<UTC time>.db` and stored in `RIPE_BACKUP_DIR` (default `<RIPE_DATA_DIR>/backups`, i.e. `/data/backups` in Docker). **By default they are on the same volume as the database**: this protects against a corrupted file, not against losing the volume. For that, mount a separate volume/host directory and set `RIPE_BACKUP_DIR` to it, or copy the directory elsewhere regularly (e.g. `docker compose cp collector:/data/backups ./backups`).
**Automatic restore.** If `ripe.db` cannot be opened as a database (any process: API, daemon, CLI), the file is moved to `ripe.db.corrupt-<timestamp>` and the newest copy that passes the integrity check is put in its place; concurrent processes are serialized with a lock. The event is logged (ERROR), written to `last_restore.json` and shown in `GET /health` as `last_restore`. Data collected after that copy is lost; the collector gathers it again on the next runs. If there is no valid copy, the behaviour is as before: the API answers `503`.
- The change journal goes back with the copy, so after a restore all cursors and times issued earlier answer `410` on `/addresses/diff` (the client makes a full download). The journal counter is shifted by 1,000,000 for that (a heuristic: it assumes fewer changes than that between two copies).
- Only corruption detected **when the database is opened** is restored automatically. Damage inside the file shows up as `503` on reads and is caught by the `backup` job (`quick_check`); restore it by hand: stop both services, keep the damaged `ripe.db*`, copy the chosen `backups/ripe-*.db` to `ripe.db`, start the services.
Manual consistent copy: `sqlite3 ripe.db ".backup ripe.db.bak"` (do not copy `ripe.db` alone while the services are running - part of the data may still be in the `-wal` file).
+7 -1
View File
@@ -52,7 +52,7 @@ class CollectRequest(BaseModel):
class ScheduleUpdate(BaseModel): class ScheduleUpdate(BaseModel):
type: Literal["asn", "fqdn"] type: Literal["asn", "fqdn", "backup"]
cron: str cron: str
model_config = {"json_schema_extra": {"example": {"type": "asn", "cron": "*/10 * * * *"}}} model_config = {"json_schema_extra": {"example": {"type": "asn", "cron": "*/10 * * * *"}}}
@@ -211,6 +211,11 @@ def health():
with db.session() as conn: with db.session() as conn:
counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")} counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")}
try:
last_restore = load_json(cc.RESTORE_FILE, None) # след автовосстановления базы из копии
except StorageError:
last_restore = None
healthy = alive and not any(j.get("last_error") for j in jobs.values()) healthy = alive and not any(j.get("last_error") for j in jobs.values())
return { return {
"status": "ok" if healthy else "degraded", "status": "ok" if healthy else "degraded",
@@ -218,6 +223,7 @@ def health():
"collector_updated_at": updated_at, "collector_updated_at": updated_at,
"jobs": jobs, "jobs": jobs,
"counts": counts, "counts": counts,
"last_restore": last_restore,
} }
+4
View File
@@ -19,9 +19,13 @@ DATA_FILE = os.path.join(DATA_DIR, "data.json")
FQDN_DATA_FILE = os.path.join(DATA_DIR, "fqdn_data.json") FQDN_DATA_FILE = os.path.join(DATA_DIR, "fqdn_data.json")
STATUS_FILE = os.path.join(DATA_DIR, "status.json") # пишет collector_daemon, читает API (/health) STATUS_FILE = os.path.join(DATA_DIR, "status.json") # пишет collector_daemon, читает API (/health)
COLLECT_REQUEST_FILE = os.path.join(DATA_DIR, "collect_request.json") # API -> демон: POST /collect COLLECT_REQUEST_FILE = os.path.join(DATA_DIR, "collect_request.json") # API -> демон: POST /collect
# Резервные копии базы (задание backup в демоне); по умолчанию на том же томе, каталог можно вынести
BACKUP_DIR = os.environ.get("RIPE_BACKUP_DIR", os.path.join(DATA_DIR, "backups"))
RESTORE_FILE = os.path.join(DATA_DIR, "last_restore.json") # след автовосстановления базы (читает /health)
BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json" BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json"
DEFAULT_TTL_DAYS = 90 DEFAULT_TTL_DAYS = 90
DEFAULT_BACKUP_KEEP = 7 # сколько копий базы хранить
DEFAULT_CHANGES_RETENTION_DAYS = 30 # срок хранения журнала изменений для /addresses/diff DEFAULT_CHANGES_RETENTION_DAYS = 30 # срок хранения журнала изменений для /addresses/diff
# Демон сверяет расписание с config.json и пишет heartbeat в status.json с этим периодом; # Демон сверяет расписание с config.json и пишет heartbeat в status.json с этим периодом;
# API считает демон живым, пока heartbeat не старше STATUS_STALE_AFTER секунд # API считает демон живым, пока heartbeat не старше STATUS_STALE_AFTER секунд
+17 -4
View File
@@ -14,13 +14,27 @@ from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
import cidr_collector as cc import cidr_collector as cc
import db
from cidr_collector import CIDRCollector, FQDNCollector, load_full_config from cidr_collector import CIDRCollector, FQDNCollector, load_full_config
from storage import StorageError, save_json_atomic, try_lock from storage import StorageError, save_json_atomic, try_lock
logger = logging.getLogger("collector_daemon") logger = logging.getLogger("collector_daemon")
DEFAULT_CRONS = {"asn": "0 2 * * *", "fqdn": "0 3 * * *"} DEFAULT_CRONS = {"asn": "0 2 * * *", "fqdn": "0 3 * * *", "backup": "30 4 * * *"}
COLLECTORS = {"asn": CIDRCollector, "fqdn": FQDNCollector}
def run_backup():
"""Задание backup: онлайн-копия базы, проверка, ротация (число копий - backup_keep в config.json)."""
keep = max(1, int(load_full_config().get("backup_keep", cc.DEFAULT_BACKUP_KEEP)))
with db.session() as conn:
path = db.backup_database(conn, datetime.datetime.now(), keep)
logger.info("Database backup written to %s (keeping the last %d)", path, keep)
# Задания: имя -> вызываемый объект (новый экземпляр на каждый запуск - свежий config.json)
COLLECTORS = {"asn": lambda: CIDRCollector().run_collection(),
"fqdn": lambda: FQDNCollector().run_collection(),
"backup": run_backup}
LOCK_FILE = os.path.join(cc.DATA_DIR, "collector.daemon.lock") LOCK_FILE = os.path.join(cc.DATA_DIR, "collector.daemon.lock")
# Состояние заданий: действующий cron, результат последнего запуска, отклонённый cron (чтобы не спамить логом) # Состояние заданий: действующий cron, результат последнего запуска, отклонённый cron (чтобы не спамить логом)
@@ -58,8 +72,7 @@ def run_job(name, scheduler):
state["running"] = True state["running"] = True
write_status(scheduler) write_status(scheduler)
try: try:
# Новый экземпляр на каждый запуск - свежий config.json COLLECTORS[name]()
COLLECTORS[name]().run_collection()
state["last_error"] = None state["last_error"] = None
except Exception as e: except Exception as e:
logger.exception("%s collection failed", name) logger.exception("%s collection failed", name)
+134 -10
View File
@@ -1,16 +1,20 @@
"""Хранение собранных адресов в SQLite (ripe.db). Конфигурация и heartbeat остаются в JSON.""" """Хранение собранных адресов в SQLite (ripe.db). Конфигурация и heartbeat остаются в JSON."""
import datetime import datetime
import glob
import logging import logging
import os import os
import shutil
import sqlite3 import sqlite3
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from storage import StorageError, load_json from storage import StorageError, file_lock, load_json, save_json_atomic
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SCHEMA_VERSION = 2 SCHEMA_VERSION = 2
# После отката к копии номера журнала сдвигаются на это число: курсоры, выданные до отката, становятся недействительными
RESTORE_JOURNAL_JUMP = 1_000_000
_UPSERT = ("INSERT INTO addresses (kind, source, value, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) " _UPSERT = ("INSERT INTO addresses (kind, source, value, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT (kind, source, value) DO UPDATE SET last_seen = excluded.last_seen") "ON CONFLICT (kind, source, value) DO UPDATE SET last_seen = excluded.last_seen")
@@ -29,28 +33,148 @@ def _quarantine(path):
return f"{path}.corrupt-{stamp}" return f"{path}.corrupt-{stamp}"
def connect(path=None): def _open(path, cc):
"""Открывает базу, создаёт схему и однократно импортирует старые data.json / fqdn_data.json.""" """Открывает базу и приводит схему к актуальной. Ошибки sqlite3 пробрасываются как есть."""
import cidr_collector as cc # пути читаем при вызове (их подменяют тесты); импорт отложен из-за цикла
path = path or cc.DB_FILE
conn = sqlite3.connect(path, timeout=5.0, isolation_level=None) # транзакциями управляем явно conn = sqlite3.connect(path, timeout=5.0, isolation_level=None) # транзакциями управляем явно
try: try:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA temp_store=MEMORY") conn.execute("PRAGMA temp_store=MEMORY")
_ensure_schema(conn, cc) _ensure_schema(conn, cc)
except sqlite3.OperationalError as e: # например, database is locked: базу не трогаем except BaseException:
conn.close() conn.close()
raise
return conn
def connect(path=None):
"""Открывает базу, создаёт схему и однократно импортирует старые data.json / fqdn_data.json.
Повреждённую базу убирает в карантин и восстанавливает из последней исправной копии (см. _recover).
"""
import cidr_collector as cc # пути читаем при вызове (их подменяют тесты); импорт отложен из-за цикла
path = path or cc.DB_FILE
try:
return _open(path, cc)
except sqlite3.OperationalError as e: # например, database is locked: базу не трогаем
raise StorageError(f"Cannot open {path}: {e}") from e raise StorageError(f"Cannot open {path}: {e}") from e
except sqlite3.DatabaseError as e: # not a database / malformed except sqlite3.DatabaseError as e: # not a database / malformed
return _recover(path, e, cc)
def list_backups(backup_dir):
"""Копии от новых к старым (имя содержит время UTC, поэтому сортировка по имени = по времени)."""
return sorted(glob.glob(os.path.join(backup_dir, "ripe-*.db")), reverse=True)
def _quick_check_file(path):
"""True, если файл открывается как база и проходит PRAGMA quick_check."""
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5.0)
try:
return conn.execute("PRAGMA quick_check").fetchone()[0] == "ok"
finally:
conn.close()
except sqlite3.Error:
return False
def _recover(path, error, cc):
"""Порча базы при открытии: карантин и восстановление из последней исправной копии.
API и демон могут обнаружить порчу одновременно, поэтому всё выполняется под блокировкой,
а внутри неё база проверяется повторно (другой процесс мог уже восстановить). Нет копий - StorageError.
"""
with file_lock(path + ".restore"):
try:
return _open(path, cc)
except sqlite3.OperationalError as e:
raise StorageError(f"Cannot open {path}: {e}") from e
except sqlite3.DatabaseError:
pass
quarantine = _quarantine(path)
logger.error("Corrupted database %s (%s); moved to %s", path, error, quarantine)
for candidate in list_backups(cc.BACKUP_DIR):
if not _quick_check_file(candidate):
logger.warning("Backup %s failed the integrity check, skipping", candidate)
continue
try:
conn = _restore_backup(candidate, path, cc)
except (sqlite3.Error, OSError) as e:
logger.warning("Cannot restore from %s: %s", candidate, e)
continue
logger.error("Database restored from backup %s; data collected after it is lost "
"and will be gathered again by the collector", candidate)
save_json_atomic(cc.RESTORE_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"),
"backup": candidate, "quarantine": quarantine, "error": str(error)})
return conn
raise StorageError(f"{path} is corrupted and no valid backup was found") from error
def _restore_backup(candidate, path, cc):
"""Кладёт копию на место базы и сбрасывает журнал изменений (его номера после точки копии уже выдавались)."""
tmp = path + ".restore.tmp"
shutil.copyfile(candidate, tmp)
os.replace(tmp, path)
conn = _open(path, cc)
try:
conn.execute("BEGIN IMMEDIATE")
try:
row = conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'changes'").fetchone()
horizon = int(conn.execute("SELECT value FROM meta WHERE key = 'horizon_id'").fetchone()[0])
new_seq = max(row[0] if row else 0, horizon) + RESTORE_JOURNAL_JUMP
conn.execute("DELETE FROM changes")
if row:
conn.execute("UPDATE sqlite_sequence SET seq = ? WHERE name = 'changes'", (new_seq,))
else:
conn.execute("INSERT INTO sqlite_sequence (name, seq) VALUES ('changes', ?)", (new_seq,))
conn.execute("UPDATE meta SET value = ? WHERE key = 'horizon_id'", (str(new_seq),))
conn.execute(f"UPDATE meta SET value = {_TS} WHERE key = 'horizon_ts'")
except BaseException:
conn.execute("ROLLBACK")
raise
conn.execute("COMMIT")
except BaseException:
conn.close() conn.close()
backup = _quarantine(path) raise
logger.error("Corrupted database %s (%s); moved to %s", path, e, backup)
raise StorageError(f"{path} is corrupted") from e
return conn return conn
def backup_database(conn, now, keep, backup_dir=None):
"""Онлайн-копия базы с проверкой и ротацией. Возвращает путь копии.
Живая база и копия проверяются PRAGMA quick_check; при неудаче копия не появляется, старые не трогаются
(исключение StorageError). Лишние старые копии (сверх keep) удаляются только после успешной новой.
"""
import cidr_collector as cc
backup_dir = backup_dir or cc.BACKUP_DIR
if conn.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise StorageError("Live database failed the integrity check, backup skipped")
os.makedirs(backup_dir, exist_ok=True)
for stale in glob.glob(os.path.join(backup_dir, "ripe-*.db.tmp")): # хвосты прерванной копии
os.unlink(stale)
stamp = now.astimezone(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
path = os.path.join(backup_dir, f"ripe-{stamp}.db")
tmp = path + ".tmp"
dst = sqlite3.connect(tmp)
try:
conn.backup(dst)
dst.execute("PRAGMA journal_mode=DELETE") # копия - один самодостаточный файл без -wal
finally:
dst.close()
if not _quick_check_file(tmp):
os.unlink(tmp)
raise StorageError("Backup failed the integrity check and was discarded")
os.replace(tmp, path)
for old in list_backups(backup_dir)[keep:]:
os.unlink(old)
return path
@contextmanager @contextmanager
def session(): def session():
"""Соединение на время блока (закрывается по выходу).""" """Соединение на время блока (закрывается по выходу)."""
+37
View File
@@ -0,0 +1,37 @@
# План: резервные копии базы и автовосстановление (доработка Б, п. 1.3-1.4)
## Цель
База `ripe.db` копируется автоматически по расписанию, а при порче файла сервис сам поднимает последнюю исправную копию вместо пустой базы (риск 3 анализа). Откат к копии заметен в логе и в `/health`, а не происходит молча.
## Дизайн
### Резервное копирование
- **Задание `backup` в демоне** (третье после `asn` и `fqdn`, те же расписание, состояние в `status.json`, защита от наложения). Расписание: `schedule.backup` в `config.json`, по умолчанию `30 4 * * *`; менять можно через `POST /schedule` (тип `backup`).
- **Как копируем:** онлайн-копия средствами SQLite (`Connection.backup`), безопасная при работающих API и сборщике (WAL, чтение снимка). Файл сначала пишется как `*.tmp`, затем переименовывается: неполная копия не появляется.
- **Проверка:** перед копированием `PRAGMA quick_check` живой базы, после копирования она же на копии. Если проверка не пройдена, копия не создаётся или удаляется, старые хорошие копии не трогаются, задание получает `last_error`, `/health` показывает `degraded`.
- **Хранение:** `RIPE_BACKUP_DIR` (по умолчанию `<DATA_DIR>/backups`), файлы `ripe-<UTC-время>.db`. Ключ `backup_keep` в `config.json` (по умолчанию 7): лишние старые копии удаляются только после успешной новой.
- **Ограничение:** по умолчанию копии лежат на том же томе `/data`, что и база. Это защищает от порчи файла, но не от потери тома. Вынос на отдельный том или хост описывается в README (переменная `RIPE_BACKUP_DIR` и копирование каталога), не автоматизируется.
### Автовосстановление
- **Где:** в `db.connect()`, там, где сейчас порченая база убирается в `*.corrupt-*`. После этого берётся самая свежая копия, проходящая `quick_check`; копия кладётся на место базы (через временный файл и `os.replace`), соединение открывается заново. Если исправных копий нет, поведение прежнее: `StorageError`, API отвечает 503.
- **Гонка API и демона:** оба процесса могут обнаружить порчу одновременно. Восстановление идёт под файловой блокировкой (`file_lock`), внутри блокировки база проверяется повторно: если другой процесс уже восстановил, повторных действий нет.
- **Курсоры diff после отката:** журнал изменений откатывается вместе с базой, и номера записей после точки копии могли уже быть выданы клиентам. Чтобы старые курсоры не совпали с новой историей, после восстановления счётчик журнала сдвигается на 1 000 000 и «горизонт» ставится на новое значение (время горизонта = момент восстановления). Все прежние курсоры и времена дают `410`, клиенты делают полную выгрузку. Это эвристика: она надёжна, пока между копиями накапливается меньше миллиона изменений (при суточной копии и редких изменениях RIPE запас большой).
- **Заметность:** запись в лог уровня ERROR и файл `last_restore.json` (время, использованная копия, путь карантина); `/health` показывает поле `last_restore`. Статус `degraded` восстановление не вызывает (сервис работает), но событие не теряется.
- **Ограничение:** автоматически ловится порча, обнаруженная при открытии базы. Порча страниц внутри файла проявится при чтении (ответ 503) и будет замечена заданием `backup` (`quick_check` не пройдёт, `/health` = `degraded`); восстановление в этом случае ручное (порядок в README).
### Что не входит
Копии вне сервера, шифрование, восстановление на момент времени, команда CLI `restore`, ручной запуск копии через `POST /collect` (можно добавить отдельно).
## Изменения
1. `cidr_collector.py`: `BACKUP_DIR`, `RESTORE_FILE`, `DEFAULT_BACKUP_KEEP`.
2. `db.py`: `backup_database()` (копия, проверка, ротация), `_restore_latest_backup()` под блокировкой, вызов из `connect()`, сдвиг журнала.
3. `collector_daemon.py`: задание `backup` (реестр `COLLECTORS` становится реестром заданий с вызываемыми объектами, `DEFAULT_CRONS["backup"]`).
4. `api_server.py`: `ScheduleUpdate.type` допускает `backup`; `/health` показывает `last_restore`.
5. `README.md`: расписание и ключи (`schedule.backup`, `backup_keep`), `RIPE_BACKUP_DIR`, ручное восстановление, вынос копий с тома.
6. Тесты (2): копия проходит проверку, ротация оставляет `backup_keep` штук, повреждённая живая база копию не создаёт; порченая база при открытии восстанавливается из копии (карантин сохранён, старые курсоры дают `410`), без копий остаётся `StorageError`.
## Проверка
Тесты в контейнере. Вручную на копии данных: задание `backup` создаёт файл, порча `ripe.db` (запись мусора) при работающих API и демоне приводит к восстановлению, `/health` показывает `last_restore`, запрос `since` со старым курсором даёт `410`. Проверка в Docker Compose (общий том).
## Откат
Удалить задание `backup` и вызов восстановления в `connect()`: схема базы не меняется (журнал и `meta` остаются), файлы копий можно удалить вручную.
+24
View File
@@ -0,0 +1,24 @@
# Итоги: резервные копии базы и автовосстановление (доработка Б)
План: `docs/plan-db-backup.md`.
## Сделано
- **Задание `backup` в демоне** (`collector_daemon.py`): третье задание рядом с `asn` и `fqdn` (то же расписание, защита от наложения, состояние в `status.json`); по умолчанию `30 4 * * *`, меняется в `schedule.backup` или через `POST /schedule` (`type: backup`). Реестр `COLLECTORS` теперь хранит вызываемые объекты, существующие тесты не менялись.
- **Копия** (`db.backup_database`): онлайн-копия средствами SQLite, `quick_check` живой базы и копии, запись через `*.tmp` и переименование, ротация (`backup_keep`, по умолчанию 7) только после успешной новой. Если проверка не пройдена, копия не создаётся, старые остаются, `/health` показывает `degraded`.
- **Автовосстановление** (`db.connect` -> `_recover`): порченая база уходит в `*.corrupt-*`, на её место встаёт самая свежая копия, прошедшая проверку (битые копии пропускаются); под файловой блокировкой, с повторной проверкой внутри (гонка API и демона). Без исправных копий прежнее поведение (`StorageError`, 503).
- **Журнал diff после отката:** журнал очищается, счётчик сдвигается на 1 000 000, горизонт на момент восстановления: все выданные ранее курсоры и времена дают `410`.
- **Заметность:** лог ERROR, файл `last_restore.json`, поле `last_restore` в `/health`.
- **Прочее:** `RIPE_BACKUP_DIR` (по умолчанию `<DATA_DIR>/backups`), `backups/` и `last_restore.json` в `.gitignore`/`.dockerignore`, `POST /schedule` принимает `backup`.
- **README:** расписание и `backup_keep`, раздел «Backup and automatic restore» (вынос копий с тома, ручное восстановление), `last_restore` в `/health`.
- **Тесты:** 2 новых (копия, ротация и проверка; восстановление с пропуском битой копии, сброс курсоров, работа после отката, случай без копий). Всего 20, в контейнере 20 passed.
## Проверка
- Отдельные процессы (демон с `backup` раз в минуту и `backup_keep=2`, API): скопировано и ротировано до двух файлов; после записи мусора в `ripe.db` API поднял базу из копии, `/addresses` вернул данные, `/health` показал `last_restore`, карантинный файл сохранён; новый курсор принимается (200).
- Docker Compose (отдельный проект, порт 18000, стенд убран): расписание `backup` выставлено через `POST /schedule`, копия появилась в `/data/backups`, порча базы в томе привела к восстановлению, оба сервиса `healthy`.
## Замечания
- Копии по умолчанию лежат на том же томе, что и база: это защита от порчи файла, но не от потери тома.
- Автоматически восстанавливается только порча, видимая при открытии; порча внутри файла даёт 503, её замечает задание `backup`, восстановление ручное (порядок в README).
- Сдвиг журнала на 1 000 000 - эвристика: старый курсор мог бы совпасть с новой историей, только если между копиями накопится больше миллиона изменений.
- Отказ `410` для старого курсора после восстановления подтверждён тестом; вручную в отдельных процессах эту проверку не повторял (команда не выполнилась из-за экранирования в оболочке).
- Не сделано: копии вне сервера, шифрование, команда CLI `restore`, ручной запуск копии через `POST /collect`.
+51
View File
@@ -1,6 +1,8 @@
import datetime import datetime
import glob
import json import json
import os import os
import sqlite3
import sys import sys
import pytest import pytest
@@ -15,6 +17,8 @@ import db
def files(tmp_path, monkeypatch): def files(tmp_path, monkeypatch):
for attr, name in (("DB_FILE", "ripe.db"), ("DATA_FILE", "data.json"), ("FQDN_DATA_FILE", "fqdn_data.json")): 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, 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 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=0) is None
assert db.get_changes(conn, {"asn"}, cursor=c3 + 1) is None assert db.get_changes(conn, {"asn"}, cursor=c3 + 1) is None
assert db.get_changes(conn, {"asn"}, cursor=c3) == (set(), set(), c3) 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()