119 lines
5.1 KiB
Python
119 lines
5.1 KiB
Python
"""
|
||
Трекер созданных инстансов.
|
||
|
||
Хранит UID'ы инстансов, созданных через приложение, в JSON-файлах.
|
||
Нужен ТОЛЬКО как краткосрочный fallback — когда инстанс создан, но ещё
|
||
не появился в ответе GET /instances (облако может задержать на секунды).
|
||
|
||
Файлы изолированы по пользователю и стенду:
|
||
/tmp/instances-{clientId}-{stand}.json
|
||
|
||
Блокировка fcntl.flock для безопасной работы с несколькими воркерами gunicorn.
|
||
При редеплое /tmp/ теряется — это НЕ критично, cloud-first выдача всё покажет.
|
||
|
||
Функции:
|
||
add(client_id, stand, uid, svc_id, name) — добавить инстанс
|
||
remove(client_id, stand, uid) — удалить (после успешного delete)
|
||
list_all(client_id, stand) — все записи списком
|
||
"""
|
||
import fcntl
|
||
import json
|
||
import os
|
||
import time
|
||
|
||
|
||
def _path(client_id, stand):
|
||
"""Путь к файлу трекера для конкретного пользователя и стенда."""
|
||
return f"/tmp/instances-{client_id}-{stand}.json"
|
||
|
||
|
||
def _acquire_lock(fd):
|
||
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
|
||
|
||
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
|
||
Если за 2 секунды не взяли лок — возвращает False (не блокируемся навечно)."""
|
||
deadline = time.time() + 2
|
||
while True:
|
||
try:
|
||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
return True
|
||
except BlockingIOError:
|
||
if time.time() >= deadline:
|
||
return False
|
||
time.sleep(0.05)
|
||
|
||
|
||
def _locked_read(path):
|
||
"""Читает JSON-файл под эксклюзивной блокировкой.
|
||
|
||
Если файла нет — создаёт и возвращает пустой {}.
|
||
Если JSON битый — возвращает {} (переживёт перезапись).
|
||
Максимальный размер: 65536 байт."""
|
||
try:
|
||
# O_RDWR — чтение+запись, O_CREAT — создать если нет
|
||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
||
except OSError:
|
||
return {} # нет прав — молча возвращаем пустой
|
||
try:
|
||
if not _acquire_lock(fd):
|
||
return {} # не смогли заблокировать за 2с — не рискуем
|
||
try:
|
||
data = os.read(fd, 65536)
|
||
if data:
|
||
return json.loads(data.decode("utf-8"))
|
||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||
pass # битый файл — переживём, при следующей записи перезапишется
|
||
return {}
|
||
finally:
|
||
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
||
os.close(fd)
|
||
|
||
|
||
def _locked_write(path, data):
|
||
"""Пишет JSON-файл под эксклюзивной блокировкой.
|
||
|
||
Полностью перезаписывает файл: lseek(0) + ftruncate + write."""
|
||
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"))
|
||
finally:
|
||
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
||
os.close(fd)
|
||
|
||
|
||
def add(client_id, stand, instance_uid, svc_id, display_name):
|
||
"""Добавить инстанс в трекер.
|
||
|
||
Читает текущий файл → добавляет запись → пишет обратно.
|
||
Если инстанс уже есть — перезаписывает (идемпотентно)."""
|
||
p = _path(client_id, stand)
|
||
data = _locked_read(p)
|
||
data[instance_uid] = {
|
||
"svcId": svc_id,
|
||
"displayName": display_name,
|
||
"instanceUid": instance_uid,
|
||
}
|
||
_locked_write(p, data)
|
||
|
||
|
||
def remove(client_id, stand, instance_uid):
|
||
"""Удалить инстанс из трекера (после успешного delete).
|
||
|
||
pop с default=None — не падает если инстанса уже нет."""
|
||
p = _path(client_id, stand)
|
||
data = _locked_read(p)
|
||
data.pop(instance_uid, None)
|
||
_locked_write(p, data)
|
||
|
||
|
||
def list_all(client_id, stand):
|
||
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}."""
|
||
return list(_locked_read(_path(client_id, stand)).values())
|