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 += ``;