v1.0.48: tracker_add sync before thread (Sonnet fix); log is_ok; flexbox buttons

This commit is contained in:
2026-07-27 09:02:59 +04:00
parent 7fdcc005b5
commit 1d306bf263
4 changed files with 166 additions and 11 deletions
+153
View File
@@ -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/<svcId> (список инстансов из трекера) |
| `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/<opUid>` каждые 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 подхватывает → редеплой
+1 -1
View File
@@ -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")
+9 -5
View File
@@ -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,11 +179,9 @@ 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:
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",
+2 -4
View File
@@ -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=>
`<div class="inst-item" style="margin-left:0;">
<button class="btn btn-sm" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>
</div>`
`<button class="btn btn-sm" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
).join('');
opsEl.classList.add('open');
}