""" Трекер созданных инстансов. Хранит 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())