From 1d306bf2637d94510528d8a82e30cb2e7cb48f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 27 Jul 2026 09:02:59 +0400 Subject: [PATCH] v1.0.48: tracker_add sync before thread (Sonnet fix); log is_ok; flexbox buttons --- DOCS/sonnet-question-tracker.md | 153 ++++++++++++++++++++++++++++++++ site/app.py | 2 +- site/routes/api_test.py | 16 ++-- site/templates/index.html | 6 +- 4 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 DOCS/sonnet-question-tracker.md diff --git a/DOCS/sonnet-question-tracker.md b/DOCS/sonnet-question-tracker.md new file mode 100644 index 0000000..4f348e9 --- /dev/null +++ b/DOCS/sonnet-question-tracker.md @@ -0,0 +1,153 @@ +# Вопрос к Sonnet — архитектура трекера инстансов + UI кнопок + +Отправлено: 27.07.2026 + +--- + +Ты — senior архитектор. Разбери проблему в Flask-приложении на Nubes pythonk8s (gunicorn, 1 worker). + +## Контекст + +Приложение app-autotest — веб-интерфейс для автотестов операций облачных сервисов Nubes. +Репозиторий: https://gitea.services.ngcloud.ru/forcloud/app-autotest.git +Версия: v1.0.47 + +## Файлы для изучения + +| Файл | Назначение | +|------|-----------| +| `site/app.py` | Точка входа Flask, VERSION, blueprints | +| `site/routes/api_test.py` | POST /api/test (запуск), GET /api/test/status (поллинг), _finish_op (фоновый поток) | +| `site/routes/main.py` | Главная страница, /api/operations/ (список инстансов из трекера) | +| `site/operations/tracker.py` | Запись/чтение /tmp/instances.json (_INITIAL, add, remove, list_all) | +| `site/templates/index.html` | UI: executeOp(), showStages(), поллинг, refreshInstances() | +| `site/api/http_client.py` | HTTP-клиент (Bearer auth, User-Agent) | + +Полные пути в репозитории: +``` +app-autotest/site/app.py +app-autotest/site/routes/api_test.py +app-autotest/site/routes/main.py +app-autotest/site/operations/tracker.py +app-autotest/site/templates/index.html +app-autotest/site/api/http_client.py +``` + +## Поток CREATE (как работает сейчас) + +1. UI: пользователь жмёт «Создать» → showParams(18, 'create') → заполняет форму → executeOp(params) +2. UI отправляет POST /api/test `{serviceId:1, operation:"create", svcOperationId:18, displayName, params, instanceUid:""}` +3. Бэкенд api_test(): + - `client.post("/instances", {serviceId, displayName})` → получает instanceUid + - `client.post("/instanceOperations", {instanceUid, operation:"create"})` → получает opUid + - `client.post("/instanceOperationCfsParams", ...)` — для каждого параметра + - `client.post("/instanceOperations/{opUid}/run")` — запуск + - `threading.Thread(target=_finish_op, args=(client, opUid, instanceUid, ...), daemon=True).start()` + - возвращает `{status:"RUNNING", opUid, instanceUid}` +4. UI начинает поллинг: `GET /api/test/status/` каждые 2 секунды +5. _finish_op (фоновый daemon-поток): + - поллит `GET /instanceOperations/{opUid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages` + - обновляет `_op_results[opUid]` (in-memory dict) + - когда `dtFinish != null` и `isSuccessful == true`: + - **вызывает tracker_add(instanceUid, svcId, displayName)** ← ПРОБЛЕМА ЗДЕСЬ + - устанавливает `_op_results[opUid] = {status:"OK", stages, duration}` + - таймаут 300 секунд +6. UI при status=="OK": вызывает `selectService(SVC_ID)` → `GET /api/operations/1` → читает tracker → показывает список + +## ПРОБЛЕМА + +Инстанс создаётся в Nubes (виден в UI платформы), НО в списке инстансов приложения НЕ появляется. +`tracker_add` не вызывается → `/tmp/instances.json` не обновляется → инстанс не в списке. + +**Происходило 2 раза подряд** (v1.0.46 и v1.0.47). Оба раза инстанс в Nubes есть, в трекере — нет. + +## Моя гипотеза + +tracker_add вызывается внутри daemon-потока _finish_op. В gunicorn: +- Воркер может быть перезапущен платформой в любой момент +- Daemon-потоки умирают вместе с воркером — молча, без логов +- Python не пишет traceback при смерти daemon-потока + +## Текущий код _finish_op (api_test.py, строки 153-195) + +```python +def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, is_delete=False): + """Фоном ждать dtFinish и сохранить результат.""" + import time + t0 = time.time() + deadline = t0 + 300 + while time.time() < deadline: + try: + data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages") + except Exception: + time.sleep(5) + continue + op = data.get("instanceOperation", {}) + dt_finish = op.get("dtFinish") + _op_results[op_uid] = { + "status": "RUNNING", + "stages": op.get("stages", []), + "duration": round(time.time() - t0, 1), + } + if dt_finish and str(dt_finish).strip(): + is_ok = op.get("isSuccessful") + err = op.get("errorLog") or "" + # Сначала трекер — чтобы UI при poll уже видел инстанс + if is_ok: + if is_create: + tracker_add(instance_uid, svc_id, display_name) + elif is_delete: + tracker_remove(instance_uid) + _op_results[op_uid] = { + "status": "OK" if is_ok else "FAIL", + "error": str(err) if err else "", + "stages": op.get("stages", []), + "duration": round(time.time() - t0, 1), + } + return + time.sleep(5) + _op_results[op_uid] = {"status": "TIMEOUT", "duration": round(time.time() - t0, 1)} +``` + +## Вопросы + +### 1. Где вызывать tracker_add? + +Вариант А: синхронно в api_test(), сразу после получения instanceUid от API (шаг 3), до запуска потока. +- Плюс: гарантированная запись, инстанс в трекере мгновенно +- Минус: если операция потом упадёт — в трекере «мусорный» инстанс (но это лучше чем отсутствие) + +Вариант Б: subprocess.Popen вместо threading.Thread. +- Плюс: процесс живёт независимо от gunicorn worker +- Минус: сложнее, нужен IPC для _op_results + +Вариант В: sqlite3 с WAL-режимом. +- Плюс: атомарная запись, конкурентный доступ +- Минус: без persistent volume данные теряются при редеплое (как и /tmp/) + +### 2. Что делать с _op_results? + +Сейчас это module-level dict. При нескольких gunicorn workers — каждый worker имеет свой dict. Нужен ли переход на sqlite/file-based storage для _op_results? + +### 3. Надёжность под gunicorn + +Как правильно организовать фоновую работу под gunicorn на pythonk8s (1 worker, нет PV, нет Redis/RabbitMQ)? + +## Дополнительно: UI кнопок + +Нужен CSS чтобы кнопки операций шли горизонтально в 1-2 ряда, как в Nubes UI: + +``` +delete | modify | suspend | resume | redeploy | reconcile +``` + +Сейчас они в vertical списке внутри `div.inst-ops`. Предложи стиль. + +## Ограничения платформы + +- `site/` — НЕ пакет (без __init__.py, конфликт с stdlib site.py) +- Импорты без префикса site.: `from api.http_client import ...` +- `app.run(host="0.0.0.0", port=5000)` — обязательно +- Gunicorn запускается платформой, предположительно 1 worker +- Нет persistent volume — данные в /tmp/ теряются при редеплое +- Деплой: git push → managed service подхватывает → редеплой diff --git a/site/app.py b/site/app.py index 942241f..b04e661 100644 --- a/site/app.py +++ b/site/app.py @@ -6,7 +6,7 @@ from routes.main import bp as main_bp from routes.api import bp as api_bp from routes.api_test import bp as api_test_bp -VERSION = "1.0.47" +VERSION = "1.0.48" app = Flask(__name__, template_folder="templates", static_folder="static") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc") diff --git a/site/routes/api_test.py b/site/routes/api_test.py index 6daf5fe..57864a5 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -111,6 +111,12 @@ def api_test(): {"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)}) client.post(f"/instanceOperations/{op_uid}/run") + # Записать в трекер СРАЗУ, до фонового потока (поток может умереть под gunicorn) + try: + tracker_add(instance_uid, svc_id, display_name) + except Exception: + pass + # фоном ждать завершения threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True), daemon=True).start() return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid}) @@ -173,12 +179,10 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ if dt_finish and str(dt_finish).strip(): is_ok = op.get("isSuccessful") err = op.get("errorLog") or "" - # Сначала трекер — чтобы UI при poll уже видел инстанс - if is_ok: - if is_create: - tracker_add(instance_uid, svc_id, display_name) - elif is_delete: - tracker_remove(instance_uid) + print(f"[DEBUG] _finish_op op_uid={op_uid} isSuccessful={is_ok!r}", flush=True) + # tracker_add для create уже вызван синхронно в api_test() + if is_ok and is_delete: + tracker_remove(instance_uid) _op_results[op_uid] = { "status": "OK" if is_ok else "FAIL", "error": str(err) if err else "", diff --git a/site/templates/index.html b/site/templates/index.html index 534a960..332e340 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -16,7 +16,7 @@ .inst-item { cursor:pointer; display:flex; align-items:center; gap:6px; padding:4px 8px; font-size:12px; border-radius:4px; } .inst-item:hover, .inst-item.active { background:var(--brand-grey-light); } .inst-ops { display:none; margin-left:16px; } - .inst-ops.open { display:block; } + .inst-ops.open { display:flex; flex-wrap:wrap; gap:4px; padding:4px 0; } .btn-sm { height:24px; font-size:11px; padding:0 8px; } .param-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; } .param-row label { width:160px; font-size:11px; text-align:right; flex-shrink:0; } @@ -139,9 +139,7 @@ async function toggleInstance(iuid){ const d=await r.json(); const ops=d.operations.filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); opsEl.innerHTML=ops.map(o=> - `
- -
` + `` ).join(''); opsEl.classList.add('open'); }