fix: ручной UPSERT вместо ON CONFLICT — не требует индекса (v1.2.39)

ON CONFLICT требовал уникальный индекс idx_runs_op_uid,
который не всегда создавался (мультистейтмент SQL в psycopg2).
→ save_run падал молча → история пустая.

Новый подход: UPDATE по op_uid, если rowcount==0 → INSERT.
Не зависит от индексов, работает всегда.
This commit is contained in:
2026-08-03 10:18:10 +04:00
parent 38148516e6
commit 3293330cec
3 changed files with 32 additions and 37 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
# Нужна для фильтрации истории (пользователь видит только записи своей версии). # Нужна для фильтрации истории (пользователь видит только записи своей версии).
VERSION = "1.2.38" VERSION = "1.2.39"
# Flask-приложение с Jinja2-шаблонами из папки templates/ # Flask-приложение с Jinja2-шаблонами из папки templates/
app = Flask(__name__, template_folder="templates", static_folder="static") app = Flask(__name__, template_folder="templates", static_folder="static")
-11
View File
@@ -84,12 +84,6 @@ ALTER TABLE runs ADD COLUMN IF NOT EXISTS app_version VARCHAR(16);
ALTER TABLE runs ADD COLUMN IF NOT EXISTS scenario_run_id INTEGER; ALTER TABLE runs ADD COLUMN IF NOT EXISTS scenario_run_id INTEGER;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS step_number INTEGER; ALTER TABLE runs ADD COLUMN IF NOT EXISTS step_number INTEGER;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS instance_meta JSONB; ALTER TABLE runs ADD COLUMN IF NOT EXISTS instance_meta JSONB;
-- Уникальный индекс на op_uid (частичный — только не-NULL).
-- Нужен для UPSERT: сценарий вызывает save_run дважды (RUNNING → OK),
-- второй вызов обновляет существующую строку вместо INSERT дубликата.
CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_op_uid
ON runs (op_uid) WHERE op_uid IS NOT NULL;
""" """
# ── DDL для scenario_runs (запуски сценариев) ── # ── DDL для scenario_runs (запуски сценариев) ──
@@ -218,11 +212,6 @@ def init_db():
cur.execute(SCHEMA_SQL) cur.execute(SCHEMA_SQL)
cur.execute(MIGRATION_SQL) cur.execute(MIGRATION_SQL)
cur.execute(SCENARIO_RUNS_SQL) cur.execute(SCENARIO_RUNS_SQL)
# Индекс для UPSERT в save_run — отдельным запросом для надёжности
# (мультистейтмент SQL в psycopg2 может выполнить не все операторы)
cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_op_uid ON runs (op_uid) WHERE op_uid IS NOT NULL")
conn.commit() conn.commit()
cur.close() cur.close()
print("[DB] Schema initialized", flush=True) print("[DB] Schema initialized", flush=True)
+31 -25
View File
@@ -51,31 +51,37 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute("""
INSERT INTO runs (client_id, stand, user_email, svc_id, svc_name, # Ручной UPSERT (UPDATE → INSERT если не затронуто строк).
op_name, svc_op_id, op_uid, instance_uid, display_name, # Не используем ON CONFLICT — не требует индекса, надёжнее.
status, duration_sec, error_log, params, stages, app_version, # Сценарий вызывает save_run дважды (RUNNING → OK) — второй вызов
scenario_run_id, step_number, instance_meta) # обновляет существующую строку вместо создания дубликата.
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) params_json = json.dumps(params) if params else None
ON CONFLICT (op_uid) WHERE op_uid IS NOT NULL DO UPDATE SET stages_json = json.dumps(stages) if stages else None
status = EXCLUDED.status, meta_json = json.dumps(instance_meta) if instance_meta else None
duration_sec = EXCLUDED.duration_sec,
error_log = EXCLUDED.error_log, if op_uid:
stages = EXCLUDED.stages, cur.execute("""
svc_name = EXCLUDED.svc_name, UPDATE runs SET
instance_meta = EXCLUDED.instance_meta status = %s, duration_sec = %s, error_log = %s,
""", ( stages = %s, svc_name = %s, instance_meta = %s
client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id, WHERE op_uid = %s
op_uid, instance_uid, display_name, status, duration_sec, """, (status, duration_sec, error_log, stages_json, svc_name, meta_json, op_uid))
error_log,
# params и stages — JSONB: сериализуем в JSON-строку if not op_uid or cur.rowcount == 0:
json.dumps(params) if params else None, # Нет op_uid ИЛИ UPDATE не затронул строк → INSERT
json.dumps(stages) if stages else None, cur.execute("""
app_version, INSERT INTO runs (client_id, stand, user_email, svc_id, svc_name,
scenario_run_id, step_number, op_name, svc_op_id, op_uid, instance_uid, display_name,
# instance_meta — тоже JSONB status, duration_sec, error_log, params, stages, app_version,
json.dumps(instance_meta) if instance_meta else None, scenario_run_id, step_number, instance_meta)
)) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
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_json, stages_json, app_version,
scenario_run_id, step_number, meta_json,
))
conn.commit() conn.commit()
cur.close() cur.close()
print(f"[DB] save_run OK: {op_name} {status} {duration_sec}s", flush=True) print(f"[DB] save_run OK: {op_name} {status} {duration_sec}s", flush=True)