Withhold addresses after the database is recreated without a backup

When ripe.db is corrupted and no valid backup exists, a new empty database
is created with a db_recreated.json marker; /addresses and /addresses/diff
answer 503 until the collector gathers data again, /health reports
db_recreated. Tests now isolate all state files via conftest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-21 09:56:43 +03:00
1 parent 46d56654d8
commit 0155fd3f38
13 files changed
+226 -32

No files matched your search

+63 -18
View File
@@ -84,7 +84,9 @@ def _recover(path, error, cc):
"""Порча базы при открытии: карантин и восстановление из последней исправной копии.
API и демон могут обнаружить порчу одновременно, поэтому всё выполняется под блокировкой,
а внутри неё база проверяется повторно (другой процесс мог уже восстановить). Нет копий - StorageError.
а внутри неё база проверяется повторно (другой процесс мог уже восстановить или пересоздать).
Нет исправных копий - создаётся новая пустая база и ставится метка db_recreated.json: пока данные
не собраны заново, API не отдаёт пустой список за настоящий (см. recreated_pending).
"""
with file_lock(path + ".restore"):
try:
@@ -109,7 +111,38 @@ def _recover(path, error, cc):
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
logger.error("No valid backup for %s: a new empty database is created; the API withholds "
"addresses until the collector gathers data again (remove %s to override)", path, cc.RECREATED_FILE)
# Метка ставится до создания базы: сбой между шагами не оставит пустую базу без метки
save_json_atomic(cc.RECREATED_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"),
"quarantine": quarantine, "error": str(error)})
conn = _open(path, cc)
try:
_reset_journal(conn)
except BaseException:
conn.close()
raise
return conn
def _reset_journal(conn):
"""Очищает журнал и сдвигает счётчик: курсоры, выданные до потери или отката базы, станут недействительными."""
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")
def _restore_backup(candidate, path, cc):
@@ -119,28 +152,40 @@ def _restore_backup(candidate, path, cc):
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")
_reset_journal(conn)
except BaseException:
conn.close()
raise
return conn
def recreated_pending(conn, kinds):
"""True, если базу пересоздали после порчи без копий и по запрошенным типам данные ещё не собраны заново.
Тип считается неготовым, когда для него в config.json есть источники, а в базе нет ни одного значения;
тип без источников готов (пустой список законен). Нечитаемый конфиг - блокировка сохраняется.
"""
import cidr_collector as cc
if not os.path.exists(cc.RECREATED_FILE):
return False
try:
config = cc.load_full_config()
except StorageError:
return True
configured = {"asn": config.get("asns"), "fqdn": config.get("fqdns")}
return any(configured[kind] and count_values(conn, kind) == 0 for kind in kinds)
def settle_recreated(conn):
"""Снимает метку пересоздания, когда данные по всем типам собраны заново. Вызывается после сбора."""
import cidr_collector as cc
if os.path.exists(cc.RECREATED_FILE) and not recreated_pending(conn, ("asn", "fqdn")):
os.remove(cc.RECREATED_FILE)
logger.info("Data gathered again after the database loss; %s removed", cc.RECREATED_FILE)
def backup_database(conn, now, keep, backup_dir=None):
"""Онлайн-копия базы с проверкой и ротацией. Возвращает путь копии.