diff --git a/panel/backend/app/db.py b/panel/backend/app/db.py index b09408e..ca47039 100644 --- a/panel/backend/app/db.py +++ b/panel/backend/app/db.py @@ -9,6 +9,12 @@ # У старой БД auto_vacuum=0: чтобы он заработал, нужен разовый VACUUM (см. vacuum_if_needed) _vacuum_pending = False +# Доля свободных страниц, при которой файл считаем распухшим и перестраиваем его +# разовым VACUUM. Штатно страницы переиспользуются под новые строки, и доля +# свободных держится около нуля; большой хвост означает разово удалённые данные +# (например, первое разряжение старой жирной БД). +VACUUM_FREE_RATIO = 0.25 + async def init_db() -> None: """Открыть БД, включить WAL, применить schema.sql.""" @@ -22,9 +28,13 @@ # существующей — только после VACUUM, поэтому запоминаем флаг. cursor = await _conn.execute("PRAGMA auto_vacuum") (auto_vacuum,) = await cursor.fetchone() - _vacuum_pending = auto_vacuum == 0 - if _vacuum_pending: + if auto_vacuum == 0: await _conn.execute("PRAGMA auto_vacuum=INCREMENTAL") + # на пустой БД (таблиц ещё нет) режим включается сразу, на существующей — + # только после VACUUM: по перечитанному значению и понимаем, нужен ли он + cursor = await _conn.execute("PRAGMA auto_vacuum") + (auto_vacuum,) = await cursor.fetchone() + _vacuum_pending = auto_vacuum == 0 await _conn.execute("PRAGMA journal_mode=WAL") await _conn.execute("PRAGMA foreign_keys=ON") schema = (Path(__file__).with_name("schema.sql")).read_text(encoding="utf-8") @@ -49,15 +59,44 @@ raise -async def vacuum_if_needed() -> bool: - """Разовый VACUUM, если у БД был auto_vacuum=0 (см. init_db). Возвращает «делали ли».""" +async def vacuum_if_needed() -> tuple[bool, str]: + """Разовый VACUUM, если БД не в incremental-режиме или файл распух от свободных страниц. + + Возвращает (делали ли, причина — для лога). + + Одного признака `auto_vacuum=0` мало: VACUUM на старте срабатывал ДО первого + разряжения (удалять ещё нечего, файл остаётся в пиковом размере), а на + следующем старте режим уже включён и VACUUM больше не запускался. Поэтому + смотрим ещё и на сам файл: `incremental_vacuum` отдаёт по одной странице за + вызов (см. `metrics_retention.thin_once`), так что без разового VACUUM + освобождённые страницы файлу не возвращаются. Нужен запас места на диске — + VACUUM пишет копию БД, при нехватке места он не делается и это не ошибка + старта. + """ global _vacuum_pending - if not _vacuum_pending or _conn is None: - return False + if _conn is None: + return False, "" + stale_mode = _vacuum_pending _vacuum_pending = False - await _conn.execute("VACUUM") - await _conn.commit() - return True + cursor = await _conn.execute("PRAGMA page_count") + (pages,) = await cursor.fetchone() + cursor = await _conn.execute("PRAGMA freelist_count") + (free,) = await cursor.fetchone() + bloated = bool(pages) and free / pages >= VACUUM_FREE_RATIO + + reason = "auto_vacuum=INCREMENTAL" if stale_mode else "" + if bloated: + share = f"{free / pages:.0%} свободных страниц" + reason = f"{reason} + {share}" if reason else share + if not reason: + return False, "" + + try: + await _conn.execute("VACUUM") + await _conn.commit() + except aiosqlite.Error as exc: # мало места на диске и т.п. — панель стартует как есть + return False, f"{reason}: VACUUM не сделан ({exc})" + return True, reason async def incremental_vacuum(pages: int = 1000) -> None: diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index 7e8c490..0b0297d 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -14,7 +14,7 @@ from app.db import close_db, init_db, vacuum_if_needed from app.events import offline_loop from app.mcp import mcp, mcp_endpoint -from app.metrics_retention import retention_loop +from app.metrics_retention import retention_loop, run_retention_pass from app.services_probe import loop as services_probe_loop from app.shares_probe import loop as shares_probe_loop @@ -26,10 +26,18 @@ @asynccontextmanager async def lifespan(_: FastAPI): await init_db() - # на старых БД один раз перестраиваем файл, включая auto_vacuum (иначе - # удалённые страницы не вернутся файлу) — до старта фоновых петель - if await vacuum_if_needed(): - print("database compacted once (auto_vacuum enabled)", flush=True) + # разряжение истории — ДО разового VACUUM: сначала удаляем лишние точки, + # потом возвращаем файлу освободившиеся страницы, иначе VACUUM срабатывает, + # когда удалять ещё нечего, и файл навсегда остаётся в пиковом размере + try: + await run_retention_pass() + except Exception as exc: # разряжение не должно мешать старту панели + print(f"metrics retention error: {exc}", flush=True) + compacted, reason = await vacuum_if_needed() + if compacted: + print(f"database compacted once ({reason})", flush=True) + elif reason: + print(f"database not compacted ({reason})", flush=True) # фоновый пробер сетевых хранилищ (share_samples) probe_task = asyncio.create_task(shares_probe_loop()) # фоновый пробер health-эндпоинтов сервисов (service_samples) diff --git a/panel/backend/app/metrics_retention.py b/panel/backend/app/metrics_retention.py index 9f9168b..efd79fa 100644 --- a/panel/backend/app/metrics_retention.py +++ b/panel/backend/app/metrics_retention.py @@ -239,22 +239,45 @@ deleted += cursor.rowcount or 0 await db.commit() if deleted: - # вернуть файлу освободившиеся страницы (нужен auto_vacuum=INCREMENTAL) + # вернуть файлу освободившиеся страницы (нужен auto_vacuum=INCREMENTAL). + # Отдаёт по одной странице за вызов — как регулярная уборка хвоста, а не + # как способ сжать распухший файл: это делает разовый VACUUM при старте + # (см. `db.vacuum_if_needed`) await incremental_vacuum(2000) return {"deleted": deleted, "buckets": buckets} +async def run_retention_pass() -> dict: + """Один проход с логом. + + Исключение не глушит — его разбирает вызывающий (старт панели не должен + падать из-за разряжения), но открытую транзакцию закрывает: иначе ошибка + на середине прохода оставила бы панель с незакрытой записью в БД. + """ + try: + result = await thin_once() + except Exception: + await get_db().rollback() + raise + if result["deleted"]: + print( + f"metrics retention: удалено {result['deleted']} точек, " + f"схлопнуто бакетов {result['buckets']}", + flush=True, + ) + return result + + async def retention_loop() -> None: - """Проход сразу при старте (деплой начинает худеть немедленно), далее по таймеру.""" + """Проходы по таймеру. + + Первый проход делает lifespan — и обязательно ДО разового VACUUM: иначе + VACUUM срабатывает, когда удалять ещё нечего, и освобождённые страницы + файлу уже не вернутся (см. `db.vacuum_if_needed`). + """ while True: + await asyncio.sleep(LOOP_INTERVAL_S) try: - result = await thin_once() - if result["deleted"]: - print( - f"metrics retention: удалено {result['deleted']} точек, " - f"схлопнуто бакетов {result['buckets']}", - flush=True, - ) + await run_retention_pass() except Exception as exc: # не роняем цикл из-за одной ошибки print(f"metrics retention error: {exc}", flush=True) - await asyncio.sleep(LOOP_INTERVAL_S)