v1.2.20: remaining Codex fixes — _op_results lock, advisory lock for scenarios, _ensure_schema logging, stale async generation token, validate-cfs explicit JSONDecodeError
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.19"
|
VERSION = "1.2.20"
|
||||||
|
|
||||||
# Flask-приложение с Jinja2-шаблонами из папки templates/
|
# Flask-приложение с Jinja2-шаблонами из папки templates/
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|||||||
+5
-2
@@ -58,8 +58,11 @@ def _ensure_schema():
|
|||||||
from db.init_db import init_db
|
from db.init_db import init_db
|
||||||
init_db()
|
init_db()
|
||||||
_initialized = True
|
_initialized = True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # без БД приложение работает (без истории)
|
print(f"[DB] Schema init FAILED: {e}", flush=True)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# Приложение продолжает работу БЕЗ БД (без истории/сценариев)
|
||||||
|
|
||||||
|
|
||||||
def get_pool():
|
def get_pool():
|
||||||
|
|||||||
+41
-12
@@ -209,27 +209,56 @@ 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 для АТОМАРНОЙ проверки-и-захвата.
|
||||||
одновременный запуск двух сценариев одним пользователем.
|
Это устраняет race condition между lock_check и INSERT scenario_runs:
|
||||||
|
если advisory lock взят — никто другой не сможет запустить сценарий
|
||||||
|
для этого же client_id+stand, пока мы его не отпустим.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True — можно запускать (нет RUNNING)
|
True — можно запускать (lock взят)
|
||||||
False — нельзя (уже есть RUNNING) → 409 Conflict"""
|
False — нельзя (другой процесс держит lock) → 409 Conflict"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return True # без БД — разрешаем (fallback)
|
return True # без БД — разрешаем (fallback)
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
# hashtext даёт стабильный int из строки — одинаковый во всех сессиях
|
||||||
SELECT id FROM scenario_runs
|
lock_key = f"scenario:{client_id}:{stand}"
|
||||||
WHERE client_id = %s AND stand = %s AND status = 'RUNNING'
|
cur.execute("SELECT pg_try_advisory_lock(hashtext(%s))", (lock_key,))
|
||||||
LIMIT 1
|
acquired = cur.fetchone()[0]
|
||||||
""", (client_id, stand))
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return row is None # None = нет RUNNING = можно запускать
|
if not acquired:
|
||||||
|
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)
|
||||||
return 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
|
||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn)
|
||||||
|
|||||||
+129
-124
@@ -32,6 +32,7 @@ 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-"
|
||||||
@@ -174,135 +175,139 @@ 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, старый формат)
|
||||||
|
|
||||||
for i, step in enumerate(steps):
|
try:
|
||||||
step_num = i + 1 # шаги нумеруются с 1 (для пользователя)
|
for i, step in enumerate(steps):
|
||||||
svc_id = step.get("service_id")
|
step_num = i + 1 # шаги нумеруются с 1 (для пользователя)
|
||||||
op_name = (step.get("operation") or "").strip()
|
svc_id = step.get("service_id")
|
||||||
symbolic_params = step.get("params", {})
|
op_name = (step.get("operation") or "").strip()
|
||||||
|
symbolic_params = step.get("params", {})
|
||||||
|
|
||||||
try:
|
|
||||||
# Валидация базовых полей шага
|
|
||||||
if not isinstance(svc_id, int) or svc_id <= 0:
|
|
||||||
raise ValueError(f"Invalid service_id: {svc_id!r}")
|
|
||||||
if not op_name:
|
|
||||||
raise ValueError("Empty operation name")
|
|
||||||
|
|
||||||
# ═══ Шаг 1: получить svcOperationId для операции ═══
|
|
||||||
detail = get_service_detail(client, svc_id)
|
|
||||||
ops = detail.get("operations", [])
|
|
||||||
op = next((o for o in ops if o.get("operation") == op_name), None)
|
|
||||||
if not op:
|
|
||||||
raise ValueError(f"Operation '{op_name}' not found for service {svc_id}")
|
|
||||||
svc_op_id = op["svcOperationId"]
|
|
||||||
|
|
||||||
# ═══ Шаг 2: резолвить параметры (символ. имена → числовые ID) ═══
|
|
||||||
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
|
||||||
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
|
||||||
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
|
||||||
|
|
||||||
# ═══ Шаг 3: резолвинг instance_uid ═══
|
|
||||||
is_create = (op_name == "create")
|
|
||||||
if is_create:
|
|
||||||
# Для create: instance_uid=None (создаём новый)
|
|
||||||
# displayName генерируем уникальный: autotest-scenario-{name}-{random6}
|
|
||||||
instance_uid = None
|
|
||||||
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
|
||||||
descr = f"scenario {scenario_name} step {step_num}"
|
|
||||||
else:
|
|
||||||
# Для не-create: резолвим instance_uid
|
|
||||||
instance_uid = _resolve_instance_uid(step, bindings, instance_map)
|
|
||||||
if not instance_uid:
|
|
||||||
raise ValueError(
|
|
||||||
f"No instance for step {step_num} — need CREATE before non-create operations")
|
|
||||||
display_name = None
|
|
||||||
descr = None
|
|
||||||
|
|
||||||
# ═══ Шаг 4: запуск через ЕДИНЫЙ executor ═══
|
|
||||||
# Это гарантирует идентичное поведение с ручным режимом
|
|
||||||
result = execute_operation(
|
|
||||||
client, svc_id, op_name, instance_uid, resolved_params,
|
|
||||||
svc_op_id=svc_op_id, display_name=display_name, descr=descr,
|
|
||||||
client_id=client_id, stand=stand
|
|
||||||
)
|
|
||||||
if not result["ok"]:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{op_name}: {result['error']} (step: {result['failed_step']})")
|
|
||||||
|
|
||||||
instance_uid = result["instance_uid"]
|
|
||||||
op_uid = result["op_uid"]
|
|
||||||
step_display = result["display_name"]
|
|
||||||
|
|
||||||
# Обновить карты для резолвинга следующих шагов
|
|
||||||
instance_map[svc_id] = instance_uid # fallback: последний инстанс сервиса
|
|
||||||
output_name = (step.get("output") or "").strip()
|
|
||||||
if is_create and output_name:
|
|
||||||
bindings[output_name] = instance_uid # именованная ссылка
|
|
||||||
_update_bindings(scenario_run_id, bindings)
|
|
||||||
|
|
||||||
# ═══ Шаг 5: сохранить RUNNING в БД (до поллинга) ═══
|
|
||||||
try:
|
try:
|
||||||
save_run(client_id, stand, user_email,
|
# Валидация базовых полей шага
|
||||||
svc_id, "", op_name, svc_op_id,
|
if not isinstance(svc_id, int) or svc_id <= 0:
|
||||||
op_uid, instance_uid, step_display,
|
raise ValueError(f"Invalid service_id: {svc_id!r}")
|
||||||
"RUNNING", 0, "",
|
if not op_name:
|
||||||
resolved_params, [], app_version,
|
raise ValueError("Empty operation name")
|
||||||
scenario_run_id, step_num)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True)
|
|
||||||
|
|
||||||
# ═══ Шаг 6: поллинг до завершения ═══
|
# ═══ Шаг 1: получить svcOperationId для операции ═══
|
||||||
poll_result = poll_until_done(client, op_uid)
|
detail = get_service_detail(client, svc_id)
|
||||||
step_status = poll_result["status"] # OK / FAIL / TIMEOUT
|
ops = detail.get("operations", [])
|
||||||
step_error = poll_result["error_log"]
|
op = next((o for o in ops if o.get("operation") == op_name), None)
|
||||||
step_duration = poll_result["duration"]
|
if not op:
|
||||||
step_stages = poll_result["stages"]
|
raise ValueError(f"Operation '{op_name}' not found for service {svc_id}")
|
||||||
svc_final = poll_result["svc"]
|
svc_op_id = op["svcOperationId"]
|
||||||
|
|
||||||
# ═══ Шаг 7: сохранить финальный результат + instance_meta ═══
|
# ═══ Шаг 2: резолвить параметры (символ. имена → числовые ID) ═══
|
||||||
try:
|
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
||||||
# Получить полную информацию об инстансе для анализа
|
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
||||||
instance_meta = None
|
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
||||||
|
|
||||||
|
# ═══ Шаг 3: резолвинг instance_uid ═══
|
||||||
|
is_create = (op_name == "create")
|
||||||
|
if is_create:
|
||||||
|
# Для create: instance_uid=None (создаём новый)
|
||||||
|
# displayName генерируем уникальный: autotest-scenario-{name}-{random6}
|
||||||
|
instance_uid = None
|
||||||
|
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
||||||
|
descr = f"scenario {scenario_name} step {step_num}"
|
||||||
|
else:
|
||||||
|
# Для не-create: резолвим instance_uid
|
||||||
|
instance_uid = _resolve_instance_uid(step, bindings, instance_map)
|
||||||
|
if not instance_uid:
|
||||||
|
raise ValueError(
|
||||||
|
f"No instance for step {step_num} — need CREATE before non-create operations")
|
||||||
|
display_name = None
|
||||||
|
descr = None
|
||||||
|
|
||||||
|
# ═══ Шаг 4: запуск через ЕДИНЫЙ executor ═══
|
||||||
|
# Это гарантирует идентичное поведение с ручным режимом
|
||||||
|
result = execute_operation(
|
||||||
|
client, svc_id, op_name, instance_uid, resolved_params,
|
||||||
|
svc_op_id=svc_op_id, display_name=display_name, descr=descr,
|
||||||
|
client_id=client_id, stand=stand
|
||||||
|
)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{op_name}: {result['error']} (step: {result['failed_step']})")
|
||||||
|
|
||||||
|
instance_uid = result["instance_uid"]
|
||||||
|
op_uid = result["op_uid"]
|
||||||
|
step_display = result["display_name"]
|
||||||
|
|
||||||
|
# Обновить карты для резолвинга следующих шагов
|
||||||
|
instance_map[svc_id] = instance_uid # fallback: последний инстанс сервиса
|
||||||
|
output_name = (step.get("output") or "").strip()
|
||||||
|
if is_create and output_name:
|
||||||
|
bindings[output_name] = instance_uid # именованная ссылка
|
||||||
|
_update_bindings(scenario_run_id, bindings)
|
||||||
|
|
||||||
|
# ═══ Шаг 5: сохранить RUNNING в БД (до поллинга) ═══
|
||||||
try:
|
try:
|
||||||
inst_data = client.get(f"/instances/{instance_uid}")
|
save_run(client_id, stand, user_email,
|
||||||
inst = inst_data.get("instance", {}) if isinstance(inst_data, dict) else {}
|
svc_id, "", op_name, svc_op_id,
|
||||||
if inst:
|
op_uid, instance_uid, step_display,
|
||||||
# Сохраняем ВСЁ кроме очевидных/избыточных полей
|
"RUNNING", 0, "",
|
||||||
instance_meta = {k: v for k, v in inst.items()
|
resolved_params, [], app_version,
|
||||||
if k not in ('instanceUid', 'displayName', 'svc', 'serviceId', 'explainedStatus')}
|
scenario_run_id, step_num)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # не смогли получить meta — не критично
|
print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True)
|
||||||
save_run(client_id, stand, user_email,
|
|
||||||
svc_id, svc_final, op_name, svc_op_id,
|
# ═══ Шаг 6: поллинг до завершения ═══
|
||||||
op_uid, instance_uid, step_display,
|
poll_result = poll_until_done(client, op_uid)
|
||||||
step_status, step_duration, step_error,
|
step_status = poll_result["status"] # OK / FAIL / TIMEOUT
|
||||||
resolved_params, step_stages, app_version,
|
step_error = poll_result["error_log"]
|
||||||
scenario_run_id, step_num,
|
step_duration = poll_result["duration"]
|
||||||
instance_meta=instance_meta)
|
step_stages = poll_result["stages"]
|
||||||
|
svc_final = poll_result["svc"]
|
||||||
|
|
||||||
|
# ═══ Шаг 7: сохранить финальный результат + instance_meta ═══
|
||||||
|
try:
|
||||||
|
# Получить полную информацию об инстансе для анализа
|
||||||
|
instance_meta = None
|
||||||
|
try:
|
||||||
|
inst_data = client.get(f"/instances/{instance_uid}")
|
||||||
|
inst = inst_data.get("instance", {}) if isinstance(inst_data, dict) else {}
|
||||||
|
if inst:
|
||||||
|
# Сохраняем ВСЁ кроме очевидных/избыточных полей
|
||||||
|
instance_meta = {k: v for k, v in inst.items()
|
||||||
|
if k not in ('instanceUid', 'displayName', 'svc', 'serviceId', 'explainedStatus')}
|
||||||
|
except Exception:
|
||||||
|
pass # не смогли получить meta — не критично
|
||||||
|
save_run(client_id, stand, user_email,
|
||||||
|
svc_id, svc_final, op_name, svc_op_id,
|
||||||
|
op_uid, instance_uid, step_display,
|
||||||
|
step_status, step_duration, step_error,
|
||||||
|
resolved_params, step_stages, app_version,
|
||||||
|
scenario_run_id, step_num,
|
||||||
|
instance_meta=instance_meta)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True)
|
||||||
|
|
||||||
|
# ═══ Шаг 8: обновить scenario_runs (прогресс для UI) ═══
|
||||||
|
# Пока не последний шаг — статус RUNNING
|
||||||
|
_save_scenario_run(scenario_run_id,
|
||||||
|
"RUNNING" if step_num < total else step_status,
|
||||||
|
step_num, round(time.time() - t0, 1),
|
||||||
|
step_error if step_status != "OK" else None)
|
||||||
|
|
||||||
|
# Если шаг FAIL — останавливаем сценарий
|
||||||
|
if step_status != "OK":
|
||||||
|
_save_scenario_run(scenario_run_id, step_status, step_num,
|
||||||
|
round(time.time() - t0, 1), step_error)
|
||||||
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True)
|
# Любая ошибка → FAIL, сценарий остановлен
|
||||||
|
import traceback
|
||||||
# ═══ Шаг 8: обновить scenario_runs (прогресс для UI) ═══
|
err = f"Step {step_num}: {e}"
|
||||||
# Пока не последний шаг — статус RUNNING
|
print(f"[SCENARIO] {err}", flush=True)
|
||||||
_save_scenario_run(scenario_run_id,
|
traceback.print_exc()
|
||||||
"RUNNING" if step_num < total else step_status,
|
_save_scenario_run(scenario_run_id, "FAIL", step_num,
|
||||||
step_num, round(time.time() - t0, 1),
|
round(time.time() - t0, 1), err)
|
||||||
step_error if step_status != "OK" else None)
|
|
||||||
|
|
||||||
# Если шаг FAIL — останавливаем сценарий
|
|
||||||
if step_status != "OK":
|
|
||||||
_save_scenario_run(scenario_run_id, step_status, step_num,
|
|
||||||
round(time.time() - t0, 1), step_error)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
# Все шаги пройдены успешно
|
||||||
# Любая ошибка → FAIL, сценарий остановлен
|
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
||||||
import traceback
|
finally:
|
||||||
err = f"Step {step_num}: {e}"
|
# Освободить advisory lock ВСЕГДА (даже при исключении)
|
||||||
print(f"[SCENARIO] {err}", flush=True)
|
unlock_scenario(client_id, stand)
|
||||||
traceback.print_exc()
|
|
||||||
_save_scenario_run(scenario_run_id, "FAIL", step_num,
|
|
||||||
round(time.time() - t0, 1), err)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Все шаги пройдены успешно
|
|
||||||
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
|
||||||
|
|||||||
@@ -245,13 +245,11 @@ def send_params_terraform(client, op_uid, params):
|
|||||||
|
|
||||||
# Шаг 7: validate-cfs — финальная проверка
|
# Шаг 7: validate-cfs — финальная проверка
|
||||||
# Если параметры невалидны — API вернёт ошибку → исключение
|
# Если параметры невалидны — API вернёт ошибку → исключение
|
||||||
# Если ответ пустой или не-JSON — это ОК (значит валидация прошла)
|
# Если ответ 200 с пустым телом — это ОК (значит валидация прошла).
|
||||||
|
# HttpClient.get() для пустого тела возвращает {} без ошибок.
|
||||||
|
# Но если тело не-JSON — будет JSONDecodeError, это тоже ОК для validate-cfs.
|
||||||
try:
|
try:
|
||||||
client.get(f"/instanceOperations/{op_uid}/validate-cfs")
|
client.get(f"/instanceOperations/{op_uid}/validate-cfs")
|
||||||
except Exception as e:
|
except json.JSONDecodeError:
|
||||||
# "Expecting value" / JSONDecodeError — это норма для validate-cfs
|
pass # пустой/не-JSON ответ — норма для validate-cfs (успех)
|
||||||
# (API возвращает 200 с пустым телом при успехе)
|
# HTTPError (4xx/5xx) пробрасывается выше — это реальная ошибка валидации
|
||||||
if "Expecting value" in str(e) or "JSON" in str(type(e).__name__):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise # реальная ошибка — пробрасываем выше
|
|
||||||
|
|||||||
+31
-24
@@ -24,6 +24,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, current_app, jsonify, request
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
@@ -261,23 +262,27 @@ def api_test():
|
|||||||
|
|
||||||
# Результаты фоновых операций: opUid → {status, error, stages, duration, _ts}
|
# Результаты фоновых операций: opUid → {status, error, stages, duration, _ts}
|
||||||
# _ts — timestamp добавления, для TTL-очистки (макс. 500 записей или старше 1 часа)
|
# _ts — timestamp добавления, для TTL-очистки (макс. 500 записей или старше 1 часа)
|
||||||
|
# ЗАЩИЩЕНО _op_results_lock — несколько потоков _finish_op + main thread api_test_status
|
||||||
_op_results = {}
|
_op_results = {}
|
||||||
|
_op_results_lock = threading.Lock()
|
||||||
_MAX_OP_RESULTS = 500
|
_MAX_OP_RESULTS = 500
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_op_results():
|
def _cleanup_op_results():
|
||||||
"""Удалить старые записи: старше 1 часа или сверх лимита."""
|
"""Удалить старые записи: старше 1 часа или сверх лимита.
|
||||||
|
Потокобезопасно — под _op_results_lock."""
|
||||||
import time
|
import time
|
||||||
now = time.time()
|
now = time.time()
|
||||||
# Удалить старше 1 часа
|
with _op_results_lock:
|
||||||
stale = [k for k, v in _op_results.items() if now - v.get("_ts", 0) > 3600]
|
# Удалить старше 1 часа (pop с default — безопасно при конкурентном доступе)
|
||||||
for k in stale:
|
stale = [k for k, v in _op_results.items() if now - v.get("_ts", 0) > 3600]
|
||||||
del _op_results[k]
|
for k in stale:
|
||||||
# Если всё ещё много — удалить самые старые
|
_op_results.pop(k, None)
|
||||||
if len(_op_results) > _MAX_OP_RESULTS:
|
# Если всё ещё много — удалить самые старые
|
||||||
sorted_keys = sorted(_op_results.keys(), key=lambda k: _op_results[k].get("_ts", 0))
|
if len(_op_results) > _MAX_OP_RESULTS:
|
||||||
for k in sorted_keys[:len(_op_results) - _MAX_OP_RESULTS]:
|
sorted_keys = sorted(_op_results.keys(), key=lambda k: _op_results[k].get("_ts", 0))
|
||||||
del _op_results[k]
|
for k in sorted_keys[:len(_op_results) - _MAX_OP_RESULTS]:
|
||||||
|
_op_results.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
def _get_instance_display_name(client, instance_uid):
|
def _get_instance_display_name(client, instance_uid):
|
||||||
@@ -291,7 +296,7 @@ def _get_instance_display_name(client, instance_uid):
|
|||||||
|
|
||||||
|
|
||||||
def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""):
|
def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""):
|
||||||
"""Фоном ждать dtFinish и сохранить результат."""
|
"""Фоном ждать dtFinish и сохранить результат (потокобезопасно для _op_results)."""
|
||||||
import time
|
import time
|
||||||
_cleanup_op_results()
|
_cleanup_op_results()
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -299,15 +304,16 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_
|
|||||||
# Поллинг через общий модуль
|
# Поллинг через общий модуль
|
||||||
poll_result = poll_until_done(client, op_uid)
|
poll_result = poll_until_done(client, op_uid)
|
||||||
|
|
||||||
# Обновить _op_results для UI
|
# Обновить _op_results для UI — под локом
|
||||||
_op_results[op_uid] = {
|
with _op_results_lock:
|
||||||
"status": poll_result["status"],
|
_op_results[op_uid] = {
|
||||||
"displayName": display_name,
|
"status": poll_result["status"],
|
||||||
"error": poll_result["error_log"],
|
"displayName": display_name,
|
||||||
"stages": poll_result["stages"],
|
"error": poll_result["error_log"],
|
||||||
"duration": poll_result["duration"],
|
"stages": poll_result["stages"],
|
||||||
"_ts": time.time(),
|
"duration": poll_result["duration"],
|
||||||
}
|
"_ts": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
# tracker_remove при успешном delete
|
# tracker_remove при успешном delete
|
||||||
if poll_result["status"] == "OK" and is_delete:
|
if poll_result["status"] == "OK" and is_delete:
|
||||||
@@ -346,10 +352,11 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_
|
|||||||
|
|
||||||
@bp.route("/api/test/status/<op_uid>")
|
@bp.route("/api/test/status/<op_uid>")
|
||||||
def api_test_status(op_uid):
|
def api_test_status(op_uid):
|
||||||
"""Получить текущий статус операции (поллинг с UI)."""
|
"""Получить текущий статус операции (поллинг с UI) — потокобезопасно."""
|
||||||
# сначала проверяем фоновый трекер
|
# сначала проверяем фоновый трекер — под локом
|
||||||
if op_uid in _op_results:
|
with _op_results_lock:
|
||||||
return jsonify(_op_results[op_uid])
|
if op_uid in _op_results:
|
||||||
|
return jsonify(_op_results[op_uid])
|
||||||
# иначе спрашиваем API напрямую
|
# иначе спрашиваем API напрямую
|
||||||
try:
|
try:
|
||||||
data = get_client().get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages")
|
data = get_client().get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages")
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ async function loadStepParams(idx) {
|
|||||||
const step = st.steps[idx];
|
const step = st.steps[idx];
|
||||||
if (!step.operation) return;
|
if (!step.operation) return;
|
||||||
|
|
||||||
|
// Запомнить поколение рендера на момент старта async-загрузки
|
||||||
|
const renderGen = st._renderGen || 0;
|
||||||
|
|
||||||
const svcOpId = await resolveStepSvcOpId(idx);
|
const svcOpId = await resolveStepSvcOpId(idx);
|
||||||
if (!svcOpId) return;
|
if (!svcOpId) return;
|
||||||
step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении
|
step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении
|
||||||
@@ -233,6 +236,8 @@ async function loadStepParams(idx) {
|
|||||||
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue
|
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue
|
||||||
const container = document.getElementById('step-' + idx + '-params');
|
const container = document.getElementById('step-' + idx + '-params');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
// Проверка: не перезаписывать DOM если уже был новый renderEditor()
|
||||||
|
if (st._renderGen !== renderGen) return;
|
||||||
let html = '';
|
let html = '';
|
||||||
defs.forEach(p => {
|
defs.forEach(p => {
|
||||||
const clone = Object.assign({}, p); // shallow copy
|
const clone = Object.assign({}, p); // shallow copy
|
||||||
@@ -259,7 +264,10 @@ async function loadStepParams(idx) {
|
|||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
|
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
|
// Инкремент поколения — защита от stale async (loadStepParams может вернуться
|
||||||
|
// уже после следующего renderEditor и перезаписать новый DOM старыми данными)
|
||||||
|
st._renderGen = (st._renderGen || 0) + 1;
|
||||||
|
const currentGen = st._renderGen;
|
||||||
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
|
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
|
||||||
|
|
||||||
// Заголовок
|
// Заголовок
|
||||||
|
|||||||
Reference in New Issue
Block a user