Newer
Older
hard-panel / panel / backend / app / db.py
import json
from pathlib import Path

import aiosqlite

from app.config import get_settings

_conn: aiosqlite.Connection | None = None
# У старой БД auto_vacuum=0: чтобы он заработал, нужен разовый VACUUM (см. vacuum_if_needed)
_vacuum_pending = False

# Доля свободных страниц, при которой файл считаем распухшим и перестраиваем его
# разовым VACUUM. Штатно страницы переиспользуются под новые строки, и доля
# свободных держится около нуля; большой хвост означает разово удалённые данные
# (например, первое разряжение старой жирной БД).
VACUUM_FREE_RATIO = 0.25


async def init_db() -> None:
    """Открыть БД, включить WAL, применить schema.sql."""
    global _conn, _vacuum_pending
    settings = get_settings()
    settings.database_path.parent.mkdir(parents=True, exist_ok=True)
    _conn = await aiosqlite.connect(settings.database_path)
    _conn.row_factory = aiosqlite.Row
    # auto_vacuum=INCREMENTAL: удалённые страницы возвращаются файлу (иначе БД
    # навсегда остаётся в пиковом размере). На пустой БД действует сразу; на
    # существующей — только после VACUUM, поэтому запоминаем флаг.
    cursor = await _conn.execute("PRAGMA auto_vacuum")
    (auto_vacuum,) = await cursor.fetchone()
    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")
    await _conn.executescript(schema)
    # Колонки, которых нет в старых БД: CREATE TABLE IF NOT EXISTS их не добавит.
    await _try_alter("ALTER TABLE service_samples ADD COLUMN report TEXT")
    await _try_alter("ALTER TABLE users ADD COLUMN system_role TEXT")
    await _try_alter("ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
    await _try_alter("ALTER TABLE services ADD COLUMN site_title TEXT")
    await _try_alter("ALTER TABLE services ADD COLUMN icon TEXT")
    await _try_alter("ALTER TABLE services ADD COLUMN meta_fetched_at TEXT")
    await _try_alter("ALTER TABLE metrics ADD COLUMN agg_json TEXT NOT NULL DEFAULT '{}'")
    await _conn.commit()


async def _try_alter(stmt: str) -> None:
    """ALTER для существующих таблиц; дубликат колонки — норма (уже добавлена)."""
    try:
        await _conn.execute(stmt)
    except aiosqlite.OperationalError as exc:
        if "duplicate column" not in str(exc).lower():
            raise


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 _conn is None:
        return False, ""
    stale_mode = _vacuum_pending
    _vacuum_pending = False
    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:
    """Вернуть файлу до pages свободных страниц (работает при auto_vacuum=INCREMENTAL)."""
    if _conn is not None:
        await _conn.execute(f"PRAGMA incremental_vacuum({int(pages)})")


async def close_db() -> None:
    global _conn
    if _conn is not None:
        await _conn.close()
        _conn = None


def get_db() -> aiosqlite.Connection:
    assert _conn is not None, "database not initialized"
    return _conn


def j(value) -> str:
    """Сериализация JSON-колонок."""
    return json.dumps(value, ensure_ascii=False)