Рефакторинг API-слоя, операций и БД

This commit is contained in:
2026-07-31 17:13:54 +04:00
parent 82a3be2067
commit 0783319fd1
15 changed files with 986 additions and 257 deletions
+67 -32
View File
@@ -1,20 +1,29 @@
"""
Трекер созданных инстансов.
Трекер созданных инстансов — краткосрочный JSON-файловый кеш.
Хранит UID'ы инстансов, созданных через приложение, в JSON-файлах.
Нужен ТОЛЬКО как краткосрочный fallback — когда инстанс создан, но ещё
не появился в ответе GET /instances (облако может задержать на секунды).
ПРОБЛЕМА: когда мы создаём инстанс через POST /instances, он появляется
в Nubes НЕ МГНОВЕННО. GET /instances может задержать новый инстанс на 5-30 секунд.
В это время пользователь не видит свой инстанс в списке → думает что create не сработал.
РЕШЕНИЕ: трекер — это JSON-файл в /tmp/, куда мы пишем UID инстанса СРАЗУ после
успешного create. UI сначала ищет инстансы в облаке (cloud-first), а то чего
нет в облаке — добирает из трекера (tracker-fallback).
Файлы изолированы по пользователю и стенду:
/tmp/instances-{clientId}-{stand}.json
Блокировка fcntl.flock для безопасной работы с несколькими воркерами gunicorn.
При редеплое /tmp/ теряется — это НЕ критично, cloud-first выдача всё покажет.
Блокировка fcntl.flock (эксклюзивная, неблокирующая с retry) —
для безопасной конкурентной работы нескольких gunicorn-воркеров.
ЖИЗНЕННЫЙ ЦИКЛ:
- add() — executor вызывает сразу после получения instanceUid
- remove() — после успешного delete (в _finish_op и CMDB-delete)
- При редеплое /tmp/ теряется — это НЕ критично (cloud-first всё покажет)
Функции:
add(client_id, stand, uid, svc_id, name) — добавить инстанс
remove(client_id, stand, uid) — удалить (после успешного delete)
list_all(client_id, stand) — все записи списком
list_all(client_id, stand) — все записи списком {svcId, displayName, instanceUid}
"""
import fcntl
import json
@@ -23,46 +32,57 @@ import time
def _path(client_id, stand):
"""Путь к файлу трекера для конкретного пользователя и стенда."""
"""Путь к файлу трекера: /tmp/instances-{clientId}-{stand}.json.
Изоляция по clientId + stand гарантирует что пользователи не видят чужие инстансы."""
return f"/tmp/instances-{client_id}-{stand}.json"
def _acquire_lock(fd):
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
Если за 2 секунды не взяли лок — возвращает False (не блокируемся навечно)."""
Почему не LOCK_EX с бесконечным ожиданием:
- При подвисании воркера все остальные повиснут навсегда.
- Лучше не записать в трекер, чем уронить запрос.
Returns:
True — блокировка взята.
False — не смогли за 2 секунды (другой воркер держит слишком долго)."""
deadline = time.time() + 2
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
return True # блокировка взята
except BlockingIOError:
if time.time() >= deadline:
return False
return False # таймаут — сдаёмся
time.sleep(0.05)
def _locked_read(path):
"""Читает JSON-файл под эксклюзивной блокировкой.
Если файла нет — создаёт и возвращает пустой {}.
Если JSON битый — возвращает {} (переживёт перезапись).
Максимальный размер: 65536 байт."""
Если файла нет — создаёт (O_CREAT) и возвращает {}.
Если JSON битый — возвращает {} (переживёт перезапись при следующем add).
Максимальный размер чтения: 65536 байт (64KB) — больше трекеру не нужно.
Returns:
dict — содержимое файла или {}."""
try:
# O_RDWR — чтение+запись, O_CREAT — создать если нет
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
except OSError:
return {} # нет прав — молча возвращаем пустой
return {} # нет прав (например /tmp/ только read) — молча
try:
if not _acquire_lock(fd):
return {} # не смогли заблокировать за 2с — не рискуем
return {} # не смогли заблокировать → не читаем (гонка данных)
try:
data = os.read(fd, 65536)
if data:
return json.loads(data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
pass # битый файл — переживём, при следующей записи перезапишется
pass # битый JSON — при следующей записи перезапишется
return {}
finally:
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
@@ -71,28 +91,40 @@ def _locked_read(path):
def _locked_write(path, data):
"""Пишет JSON-файл под эксклюзивной блокировкой.
Полностью перезаписывает файл: lseek(0) + ftruncate + write."""
Полностью перезаписывает файл: lseek(0) + ftruncate + write.
Это атомарно под локом — другие воркеры увидят либо старую, либо новую версию.
Если не смогли взять лок — НЕ пишем.
Данные не потеряются — инстанс создан в Nubes, cloud-first его подхватит."""
try:
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
except OSError:
return
try:
if not _acquire_lock(fd):
return # не смогли заблокировать — не пишем (данные не потеряются, просто не сохранятся)
os.lseek(fd, 0, 0) # в начало файла
os.ftruncate(fd, 0) # обрезать старый контент
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
return # не смогли заблокировать — пропускаем запись
# Три шага атомарной перезаписи:
os.lseek(fd, 0, 0) # 1. В начало файла
os.ftruncate(fd, 0) # 2. Обрезать старый контент
os.write(fd, json.dumps(data, indent=2).encode("utf-8")) # 3. Записать новый
finally:
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name):
"""Добавить инстанс в трекер.
Читает текущий файл → добавляет запись → пишет обратно.
Если инстанс уже есть — перезаписывает (идемпотентно)."""
"""Добавить инстанс в трекер (вызывается из executor сразу после create).
Читает текущий файл → добавляет/обновляет запись → пишет обратно.
Если инстанс уже есть — перезаписывает (идемпотентность).
Args:
client_id: str — ClientID из JWT
stand: str — "dev"/"test"
instance_uid: str — UUID созданного инстанса
svc_id: int — ID сервиса
display_name: str — displayName инстанса"""
p = _path(client_id, stand)
data = _locked_read(p)
data[instance_uid] = {
@@ -105,8 +137,8 @@ def add(client_id, stand, instance_uid, svc_id, display_name):
def remove(client_id, stand, instance_uid):
"""Удалить инстанс из трекера (после успешного delete).
pop с default=None — не падает если инстанса уже нет."""
pop с default=None — не падает если инстанса уже нет (уже удалён ранее)."""
p = _path(client_id, stand)
data = _locked_read(p)
data.pop(instance_uid, None)
@@ -114,5 +146,8 @@ def remove(client_id, stand, instance_uid):
def list_all(client_id, stand):
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}."""
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}.
Используется в main.py → api_operations для tracker-fallback.
Конвертирует dict (indexed by UUID) в list."""
return list(_locked_read(_path(client_id, stand)).values())