v1.2.21: fix broken advisory lock (→ partial unique index), escName add " escape, lock_check fallback False
This commit is contained in:
+1
-1
@@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
|
|||||||
|
|
||||||
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
|
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
|
||||||
# Нужна для фильтрации истории (пользователь видит только записи своей версии).
|
# Нужна для фильтрации истории (пользователь видит только записи своей версии).
|
||||||
VERSION = "1.2.20"
|
VERSION = "1.2.21"
|
||||||
|
|
||||||
# Flask-приложение с Jinja2-шаблонами из папки templates/
|
# Flask-приложение с Jinja2-шаблонами из папки templates/
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
|
|||||||
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
|
||||||
ON scenario_runs (client_id, stand, 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
|
-- Миграции для scenario_runs
|
||||||
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
|
||||||
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
|
||||||
|
|||||||
+15
-42
@@ -209,56 +209,29 @@ def delete_definition(def_id, client_id, stand):
|
|||||||
def lock_check(client_id, stand):
|
def lock_check(client_id, stand):
|
||||||
"""Проверить что нет активного RUNNING-запуска сценария.
|
"""Проверить что нет активного RUNNING-запуска сценария.
|
||||||
|
|
||||||
Использует pg_try_advisory_lock для АТОМАРНОЙ проверки-и-захвата.
|
Атомарность гарантируется partial unique index idx_one_running
|
||||||
Это устраняет race condition между lock_check и INSERT scenario_runs:
|
на уровне БД (см. init_db.py). Этот метод — быстрая предпроверка
|
||||||
если advisory lock взят — никто другой не сможет запустить сценарий
|
для красивого 409 до попытки INSERT.
|
||||||
для этого же client_id+stand, пока мы его не отпустим.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True — можно запускать (lock взят)
|
True — можно запускать (нет RUNNING в БД)
|
||||||
False — нельзя (другой процесс держит lock) → 409 Conflict"""
|
False — нельзя (уже есть RUNNING) → 409 Conflict"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return True # без БД — разрешаем (fallback)
|
return True # без БД — разрешаем (partial unique index недоступен)
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
# hashtext даёт стабильный int из строки — одинаковый во всех сессиях
|
cur.execute("""
|
||||||
lock_key = f"scenario:{client_id}:{stand}"
|
SELECT id FROM scenario_runs
|
||||||
cur.execute("SELECT pg_try_advisory_lock(hashtext(%s))", (lock_key,))
|
WHERE client_id = %s AND stand = %s AND status = 'RUNNING'
|
||||||
acquired = cur.fetchone()[0]
|
LIMIT 1
|
||||||
|
""", (client_id, stand))
|
||||||
|
row = cur.fetchone()
|
||||||
cur.close()
|
cur.close()
|
||||||
if not acquired:
|
return row is None # None = нет RUNNING = можно запускать
|
||||||
put_conn(conn)
|
|
||||||
return False # lock уже взят другим процессом
|
|
||||||
# НЕ возвращаем conn в пул! Держим до unlock.
|
|
||||||
# conn будет передан в сценарий и освобождён после завершения.
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DEFS] lock_check error: {e}", flush=True)
|
print(f"[DEFS] lock_check error: {e}", flush=True)
|
||||||
put_conn(conn)
|
# При ошибке БД — НЕ разрешаем (безопасный fallback)
|
||||||
return True # fallback
|
return False
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ from operations.executor import execute_operation
|
|||||||
from operations.poll import poll_until_done
|
from operations.poll import poll_until_done
|
||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
from db.save_run import save_run
|
from db.save_run import save_run
|
||||||
from db.scenario_defs import unlock_scenario
|
|
||||||
|
|
||||||
# Все инстансы созданные сценарием имеют такой префикс в displayName
|
# Все инстансы созданные сценарием имеют такой префикс в displayName
|
||||||
AUTOTEST_PREFIX = "autotest-scenario-"
|
AUTOTEST_PREFIX = "autotest-scenario-"
|
||||||
@@ -175,7 +174,6 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
|||||||
bindings = {} # output_name → instance_uid (новый формат, именованные ссылки)
|
bindings = {} # output_name → instance_uid (новый формат, именованные ссылки)
|
||||||
instance_map = {} # service_id → instance_uid (fallback, старый формат)
|
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 (для пользователя)
|
step_num = i + 1 # шаги нумеруются с 1 (для пользователя)
|
||||||
svc_id = step.get("service_id")
|
svc_id = step.get("service_id")
|
||||||
@@ -308,6 +306,4 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
|||||||
|
|
||||||
# Все шаги пройдены успешно
|
# Все шаги пройдены успешно
|
||||||
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
||||||
finally:
|
|
||||||
# Освободить advisory lock ВСЕГДА (даже при исключении)
|
|
||||||
unlock_scenario(client_id, stand)
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ async function toggleScenarioSteps(defId) {
|
|||||||
// \ → \\ (backslash)
|
// \ → \\ (backslash)
|
||||||
// ' → \' (terminate JS string literal)
|
// ' → \' (terminate JS string literal)
|
||||||
// HTML-escape уже не нужен — внутри JS-строки в атрибуте HTML-теги не парсятся.
|
// HTML-escape уже не нужен — внутри JS-строки в атрибуте HTML-теги не парсятся.
|
||||||
const escName = def.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
const escName = def.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
|
||||||
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
|
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
|
||||||
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
|
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
|
||||||
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${escName}')">📋 Копировать</button>`;
|
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${escName}')">📋 Копировать</button>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user