Рефакторинг API-слоя, операций и БД

This commit is contained in:
2026-07-31 17:13:54 +04:00
parent 82a3be2067
commit 0783319fd1
15 changed files with 986 additions and 257 deletions
+71 -20
View File
@@ -1,25 +1,45 @@
"""
Инициализация схемы БД — идемпотентно (CREATE IF NOT EXISTS).
Инициализация схемы БД — идемпотентно (CREATE IF NOT EXISTS) + startup cleanup.
Вызывается при старте приложения (в app.py, до register_blueprint).
Безопасно для нескольких gunicorn-воркеров — PostgreSQL корректно
обрабатывает конкурентный DDL.
Вызывается при ПЕРВОМ обращении к БД в каждом gunicorn-воркере.
Безопасно для конкурентного вызова из нескольких воркеров — PostgreSQL
корректно обрабатывает одновременный CREATE IF NOT EXISTS.
Таблица runs — история запусков операций:
- id, created_at
- client_id, stand (изоляция по пользователю и стенду)
- user_email (кто запустил)
- svc_id, svc_name, op_name, svc_op_id
- instance_uid, display_name, op_uid
- status (OK/FAIL/TIMEOUT), duration_sec, error_log
- params (JSONB), stages (JSONB)
- app_version (версия приложения)
ТАБЛИЦЫ:
runs — история ВСЕХ запусков операций:
- Ручной режим: каждая операция (create/modify/delete/...) — одна строка
- Сценарный режим: каждый шаг сценария — одна строка
- Колонки: id, client_id, stand, user_email, svc_id, svc_name, op_name,
svc_op_id, op_uid, instance_uid, display_name, status, duration_sec,
error_log, params (JSONB), stages (JSONB), app_version,
scenario_run_id, step_number, instance_meta (JSONB)
scenario_runs — история запусков сценариев:
- Одна строка = один запуск сценария
- Колонки: id, client_id, stand, scenario_name, status (RUNNING/OK/FAIL/TIMEOUT),
current_step, total_steps, instance_bindings (JSONB), duration_sec, error_log,
definition_id, definition_version
scenario_definitions — определения сценариев (редактируются через UI):
- Колонки: id, client_id, stand, name, steps (JSONB), version,
is_active, created_at, updated_at, updated_by
- Уникальность: (client_id, stand, LOWER(name)) — нельзя два сценария с одинаковым именем
STARTUP CLEANUP:
При старте приложения все зависшие RUNNING-сценарии (старше 1 часа)
переводятся в TIMEOUT. Это чистит последствия падения gunicorn-воркера.
SEED:
При первом старте импортируются дефолтные сценарии из scenario_seed.yaml.
INSERT ON CONFLICT DO NOTHING — не перезаписывает существующие.
"""
import os
import json
from db.pool import get_conn, put_conn
# ── DDL для таблицы runs (история операций) ──
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS runs (
id SERIAL PRIMARY KEY,
@@ -42,17 +62,21 @@ CREATE TABLE IF NOT EXISTS runs (
app_version VARCHAR(16)
);
-- Индекс для фильтрации по пользователю и стенду (основной запрос истории)
CREATE INDEX IF NOT EXISTS idx_runs_client_stand
ON runs (client_id, stand, created_at DESC);
-- Индекс для поиска по instance_uid (связь с scenario_runs)
CREATE INDEX IF NOT EXISTS idx_runs_instance
ON runs (instance_uid);
-- Индекс по дате создания (для очистки старых записей)
CREATE INDEX IF NOT EXISTS idx_runs_created
ON runs (created_at);
"""
# Миграция для существующих таблиц (добавляем колонки если их нет)
# ── Миграции: добавляем колонки которых может не быть в старых БД ──
# ALTER TABLE ADD COLUMN IF NOT EXISTS — безопасно, не ломает существующие данные
MIGRATION_SQL = """
ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64);
ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128);
@@ -62,7 +86,7 @@ ALTER TABLE runs ADD COLUMN IF NOT EXISTS step_number INTEGER;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS instance_meta JSONB;
"""
# Таблица сценариев — один запуск = одна строка
# ── DDL для scenario_runs (запуски сценариев) ──
SCENARIO_RUNS_SQL = """
CREATE TABLE IF NOT EXISTS scenario_runs (
id SERIAL PRIMARY KEY,
@@ -80,16 +104,19 @@ CREATE TABLE IF NOT EXISTS scenario_runs (
app_version VARCHAR(16)
);
-- Индекс для списка последних запусков (основной запрос)
CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
ON scenario_runs (client_id, stand, created_at DESC);
-- Индекс для проверки lock (есть ли RUNNING)
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
ON scenario_runs (client_id, stand, status);
-- Миграции для scenario_runs
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
-- Таблица определений сценариев — редактируются без редеплоя
-- ── DDL для scenario_definitions (определения, редактируются через UI) ──
CREATE TABLE IF NOT EXISTS scenario_definitions (
id SERIAL PRIMARY KEY,
client_id VARCHAR(64) NOT NULL,
@@ -103,16 +130,28 @@ CREATE TABLE IF NOT EXISTS scenario_definitions (
updated_by VARCHAR(128)
);
-- Уникальность имени в рамках пользователя и стенда
CREATE UNIQUE INDEX IF NOT EXISTS idx_scenario_defs_unique
ON scenario_definitions (client_id, stand, LOWER(name));
-- Индекс для фильтрации активных
CREATE INDEX IF NOT EXISTS idx_scenario_defs_active
ON scenario_definitions (client_id, stand, is_active);
"""
def _seed_scenarios():
"""Импорт дефолтных сценариев из scenario_seed.yaml при первом старте."""
"""Импорт дефолтных сценариев из scenario_seed.yaml при первом старте.
Формат YAML:
scenarios:
- name: "dummy_test"
client_id: "" # пустой = seed (доступен всем)
stand: "" # пустой = seed
steps: [...]
INSERT ON CONFLICT DO NOTHING — если пользователь уже создал сценарий
с таким именем, seed не перезапишет его."""
try:
import yaml, os
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scenario_seed.yaml")
@@ -142,7 +181,15 @@ def _seed_scenarios():
def init_db():
"""Применить схему + миграции — идемпотентно, безопасно для конкурентного вызова."""
"""Применить схему + миграции — идемпотентно, безопасно для конкуретного вызова.
Порядок:
1. Создать таблицы (CREATE IF NOT EXISTS)
2. Применить миграции (ALTER TABLE ADD COLUMN IF NOT EXISTS)
3. Startup cleanup: зависшие RUNNING → TIMEOUT
4. Seed дефолтных сценариев
"""
# Без DB_USER приложение работает без БД (без истории)
if not os.getenv("DB_USER"):
print("[DB] DB_USER not set — skipping init", flush=True)
return
@@ -154,6 +201,8 @@ def init_db():
try:
cur = conn.cursor()
# Шаг 1-2: схема + миграции
cur.execute(SCHEMA_SQL)
cur.execute(MIGRATION_SQL)
cur.execute(SCENARIO_RUNS_SQL)
@@ -161,7 +210,9 @@ def init_db():
cur.close()
print("[DB] Schema initialized", flush=True)
# Startup cleanup: зависшие RUNNING сценарии старше 1 часа → TIMEOUT
# Шаг 3: Startup cleanup — перевести зависшие RUNNING в TIMEOUT
# Если gunicorn упал во время выполнения сценария, статус остался RUNNING.
# Чистим всё что старше 1 часа — операция точно не могла длиться дольше.
try:
cur = conn.cursor()
cur.execute("""
@@ -183,5 +234,5 @@ def init_db():
finally:
put_conn(conn)
# Seed сценариев — только при первом старте (INSERT ON CONFLICT DO NOTHING)
# Шаг 4: Seed сценариев (вне основной транзакции)
_seed_scenarios()