v1.0.48: tracker_add sync before thread (Sonnet fix); log is_ok; flexbox buttons
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user