feat: implement Layer 2 per-file transit, RAM session, mock buffer, and tests (v0.2.0)

This commit is contained in:
“Naeel”
2026-09-06 12:01:56 +03:00
parent 0ea7ba058c
commit 112c84f11a
34 changed files with 2200 additions and 162 deletions
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле, общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)