From 06eb46308871f7cc87b3304440824fdd82cf1892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 31 Jul 2026 18:17:09 +0400 Subject: [PATCH] =?UTF-8?q?v1.2.21:=20fix=20broken=20advisory=20lock=20(?= =?UTF-8?q?=E2=86=92=20partial=20unique=20index),=20escName=20add=20"?= =?UTF-8?q?=20escape,=20lock=5Fcheck=20fallback=20False?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/app.py | 2 +- site/db/init_db.py | 6 ++++ site/db/scenario_defs.py | 57 +++++++++------------------------ site/operations/scenario.py | 12 +++---- site/static/js/scenario-list.js | 2 +- 5 files changed, 27 insertions(+), 52 deletions(-) diff --git a/site/app.py b/site/app.py index 6177297..803cf82 100644 --- a/site/app.py +++ b/site/app.py @@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Нужна для фильтрации истории (пользователь видит только записи своей версии). -VERSION = "1.2.20" +VERSION = "1.2.21" # Flask-приложение с Jinja2-шаблонами из папки templates/ app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/site/db/init_db.py b/site/db/init_db.py index 9bc7345..339c92c 100644 --- a/site/db/init_db.py +++ b/site/db/init_db.py @@ -112,6 +112,12 @@ CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand CREATE INDEX IF NOT EXISTS idx_scenario_runs_status ON scenario_runs (client_id, stand, status); +-- Partial unique index — атомарный lock на уровне БД. +-- Гарантирует что только ОДИН сценарий может быть RUNNING для client_id+stand. +-- Вторая параллельная вставка получит unique violation → 409 без гонок. +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running + ON scenario_runs (client_id, stand) WHERE status = 'RUNNING'; + -- Миграции для 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; diff --git a/site/db/scenario_defs.py b/site/db/scenario_defs.py index f7a9628..48fa687 100644 --- a/site/db/scenario_defs.py +++ b/site/db/scenario_defs.py @@ -209,56 +209,29 @@ def delete_definition(def_id, client_id, stand): def lock_check(client_id, stand): """Проверить что нет активного RUNNING-запуска сценария. - Использует pg_try_advisory_lock для АТОМАРНОЙ проверки-и-захвата. - Это устраняет race condition между lock_check и INSERT scenario_runs: - если advisory lock взят — никто другой не сможет запустить сценарий - для этого же client_id+stand, пока мы его не отпустим. + Атомарность гарантируется partial unique index idx_one_running + на уровне БД (см. init_db.py). Этот метод — быстрая предпроверка + для красивого 409 до попытки INSERT. Returns: - True — можно запускать (lock взят) - False — нельзя (другой процесс держит lock) → 409 Conflict""" + True — можно запускать (нет RUNNING в БД) + False — нельзя (уже есть RUNNING) → 409 Conflict""" conn = get_conn() if not conn: - return True # без БД — разрешаем (fallback) + return True # без БД — разрешаем (partial unique index недоступен) try: cur = conn.cursor() - # hashtext даёт стабильный int из строки — одинаковый во всех сессиях - lock_key = f"scenario:{client_id}:{stand}" - cur.execute("SELECT pg_try_advisory_lock(hashtext(%s))", (lock_key,)) - acquired = cur.fetchone()[0] + cur.execute(""" + SELECT id FROM scenario_runs + WHERE client_id = %s AND stand = %s AND status = 'RUNNING' + LIMIT 1 + """, (client_id, stand)) + row = cur.fetchone() cur.close() - if not acquired: - put_conn(conn) - return False # lock уже взят другим процессом - # НЕ возвращаем conn в пул! Держим до unlock. - # conn будет передан в сценарий и освобождён после завершения. - return True + return row is None # None = нет RUNNING = можно запускать except Exception as e: print(f"[DEFS] lock_check error: {e}", flush=True) - put_conn(conn) - return True # fallback - - -def unlock_scenario(client_id, stand, conn=None): - """Освободить advisory lock после завершения сценария. - - Вызывается из run_scenario (scenario.py) после завершения ВСЕХ шагов. - conn — то же соединение, на котором был взят lock (если есть).""" - if not conn: - conn = get_conn() - if not conn: - return - try: - lock_key = f"scenario:{client_id}:{stand}" - cur = conn.cursor() - cur.execute("SELECT pg_advisory_unlock(hashtext(%s))", (lock_key,)) - cur.close() - conn.commit() - except Exception as e: - print(f"[DEFS] unlock_scenario error: {e}", flush=True) - try: - conn.rollback() - except Exception: - pass + # При ошибке БД — НЕ разрешаем (безопасный fallback) + return False finally: put_conn(conn) diff --git a/site/operations/scenario.py b/site/operations/scenario.py index fc599f0..d28ea2c 100644 --- a/site/operations/scenario.py +++ b/site/operations/scenario.py @@ -32,7 +32,6 @@ from operations.executor import execute_operation from operations.poll import poll_until_done from db.pool import get_conn, put_conn from db.save_run import save_run -from db.scenario_defs import unlock_scenario # Все инстансы созданные сценарием имеют такой префикс в displayName AUTOTEST_PREFIX = "autotest-scenario-" @@ -175,8 +174,7 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena bindings = {} # output_name → instance_uid (новый формат, именованные ссылки) instance_map = {} # service_id → instance_uid (fallback, старый формат) - try: - for i, step in enumerate(steps): + for i, step in enumerate(steps): step_num = i + 1 # шаги нумеруются с 1 (для пользователя) svc_id = step.get("service_id") op_name = (step.get("operation") or "").strip() @@ -306,8 +304,6 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena round(time.time() - t0, 1), err) return - # Все шаги пройдены успешно - _save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1)) - finally: - # Освободить advisory lock ВСЕГДА (даже при исключении) - unlock_scenario(client_id, stand) + # Все шаги пройдены успешно + _save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1)) + diff --git a/site/static/js/scenario-list.js b/site/static/js/scenario-list.js index 1842c76..1a8abf7 100644 --- a/site/static/js/scenario-list.js +++ b/site/static/js/scenario-list.js @@ -125,7 +125,7 @@ async function toggleScenarioSteps(defId) { // \ → \\ (backslash) // ' → \' (terminate JS string literal) // HTML-escape уже не нужен — внутри JS-строки в атрибуте HTML-теги не парсятся. - const escName = def.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + const escName = def.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"'); html += ``; html += ``; html += ``;