@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Темп миграций Alembic / schema_bootstrap для слабой флешки (SD на Raspberry Pi).
|
||||
|
||||
Паузы и PRAGMA journal_mode=DELETE + synchronous=FULL снижают пиковую нагрузку на носитель
|
||||
во время DDL; после миграции рабочие подключения снова получают WAL из create_app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_arm_platform() -> bool:
|
||||
machine = platform.machine().lower()
|
||||
return any(part in machine for part in ("arm", "aarch64"))
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def migration_pause_sec() -> float:
|
||||
"""Пауза между крупными фазами (до/после upgrade, между bind)."""
|
||||
default = 1.5 if is_arm_platform() else 0.0
|
||||
return _env_float("WESP_MIGRATION_PAUSE_SEC", default)
|
||||
|
||||
|
||||
def migration_pause_bind_sec() -> float:
|
||||
"""Пауза между recipes.db и reports.db в env.py."""
|
||||
default = 3.0 if is_arm_platform() else 0.5
|
||||
return _env_float("WESP_MIGRATION_PAUSE_BIND_SEC", default)
|
||||
|
||||
|
||||
def migration_pause_step_sec() -> float:
|
||||
"""Короткая пауза после каждой таблицы / колонки в schema_bootstrap."""
|
||||
default = 0.2 if is_arm_platform() else 0.0
|
||||
return _env_float("WESP_MIGRATION_PAUSE_STEP_SEC", default)
|
||||
|
||||
|
||||
def migration_sqlite_journal_mode() -> str:
|
||||
"""Пусто по умолчанию: смена journal_mode (DELETE) даёт database is locked с WAL."""
|
||||
raw = os.getenv("WESP_MIGRATION_SQLITE_JOURNAL_MODE", "").strip()
|
||||
if raw:
|
||||
return raw.upper()
|
||||
# Только на Pi явно можно задать DELETE в wesp.env; без env — не трогаем режим.
|
||||
return ""
|
||||
|
||||
|
||||
def migration_sqlite_synchronous() -> str:
|
||||
return (os.getenv("WESP_MIGRATION_SQLITE_SYNCHRONOUS", "FULL") or "FULL").strip().upper()
|
||||
|
||||
|
||||
def migration_sqlite_busy_timeout_ms() -> int:
|
||||
raw = os.getenv("WESP_MIGRATION_SQLITE_BUSY_TIMEOUT_MS", "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return max(1000, int(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
return 60000
|
||||
|
||||
|
||||
def apply_sqlite_migration_pragmas(dbapi_connection: Any) -> None:
|
||||
"""На время DDL: меньше соседних -wal/-shm и более предсказуемый fsync."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute(f"PRAGMA busy_timeout={migration_sqlite_busy_timeout_ms()}")
|
||||
sync = migration_sqlite_synchronous()
|
||||
if sync:
|
||||
cursor.execute(f"PRAGMA synchronous={sync}")
|
||||
cursor.execute("PRAGMA cache_size=-2000")
|
||||
|
||||
jm = migration_sqlite_journal_mode()
|
||||
if not jm:
|
||||
return
|
||||
# journal_mode требует эксклюзивной блокировки; при неудаче DDL всё равно идёт в текущем режиме.
|
||||
for attempt in range(12):
|
||||
try:
|
||||
cursor.execute(f"PRAGMA journal_mode={jm}")
|
||||
break
|
||||
except Exception as exc:
|
||||
msg = str(exc).lower()
|
||||
if "locked" not in msg and "busy" not in msg:
|
||||
raise
|
||||
if attempt >= 11:
|
||||
_log.warning(
|
||||
"PRAGMA journal_mode=%s пропущен (файл занят): %s",
|
||||
jm,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
time.sleep(0.35 * (attempt + 1))
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def sqlite_path_from_uri(uri: str) -> Optional[str]:
|
||||
try:
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
u = make_url(str(uri))
|
||||
if u.drivername != "sqlite" or not u.database or u.database == ":memory:":
|
||||
return None
|
||||
path = str(u.database)
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.abspath(path)
|
||||
return path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _checkpoint_sqlite_file(path: str) -> None:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(path, timeout=60)
|
||||
try:
|
||||
conn.execute(f"PRAGMA busy_timeout={migration_sqlite_busy_timeout_ms()}")
|
||||
try:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" not in str(exc).lower() and "busy" not in str(exc).lower():
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def release_sqlalchemy_sqlite_locks(app: Any) -> None:
|
||||
"""Закрывает пулы Flask-SQLAlchemy и сбрасывает WAL перед Alembic."""
|
||||
from app import db # noqa: WPS433
|
||||
|
||||
uris: list[str] = [str(app.config.get("SQLALCHEMY_DATABASE_URI") or "")]
|
||||
binds = app.config.get("SQLALCHEMY_BINDS") or {}
|
||||
if binds.get("reports"):
|
||||
uris.append(str(binds["reports"]))
|
||||
|
||||
for uri in uris:
|
||||
path = sqlite_path_from_uri(uri)
|
||||
if not path or not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
_checkpoint_sqlite_file(path)
|
||||
except Exception:
|
||||
_log.warning("WAL checkpoint перед миграцией: %s", path, exc_info=True)
|
||||
|
||||
try:
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
except Exception:
|
||||
_log.debug("release_sqlalchemy_sqlite_locks: session.remove", exc_info=True)
|
||||
|
||||
engines: list[Any] = []
|
||||
try:
|
||||
engines.extend(db.engines.values())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if db.engine not in engines:
|
||||
engines.append(db.engine)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for eng in engines:
|
||||
try:
|
||||
if eng is not None:
|
||||
eng.dispose()
|
||||
except Exception:
|
||||
_log.debug("dispose engine failed", exc_info=True)
|
||||
|
||||
if uris:
|
||||
time.sleep(0.4)
|
||||
|
||||
|
||||
def sqlite_flush_after_migration(dbapi_connection: Any) -> None:
|
||||
"""Сброс буферов перед паузой (если до этого был WAL)."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
try:
|
||||
cursor.execute("PRAGMA wal_checkpoint(FULL)")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cursor.execute("PRAGMA optimize")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def migration_pause(phase: str, *, seconds: Optional[float] = None) -> None:
|
||||
sec = migration_pause_sec() if seconds is None else max(0.0, float(seconds))
|
||||
if sec <= 0:
|
||||
return
|
||||
_log.info("Миграция: пауза %.1f с (%s)", sec, phase)
|
||||
time.sleep(sec)
|
||||
|
||||
|
||||
def migration_pause_bind(phase: str) -> None:
|
||||
sec = migration_pause_bind_sec()
|
||||
if sec <= 0:
|
||||
return
|
||||
_log.info("Миграция: пауза между БД %.1f с (%s)", sec, phase)
|
||||
time.sleep(sec)
|
||||
|
||||
|
||||
def migration_pause_step(phase: str) -> None:
|
||||
sec = migration_pause_step_sec()
|
||||
if sec <= 0:
|
||||
return
|
||||
_log.debug("Миграция: шаг %.2f с (%s)", sec, phase)
|
||||
time.sleep(sec)
|
||||
Reference in New Issue
Block a user