From 33ebcb1a82a4d19685ff19996c440e0ed1dda542 Mon Sep 17 00:00:00 2001 From: Azamat Date: Thu, 2 Apr 2026 13:12:34 +0300 Subject: [PATCH] ref #6: [feat] add impl in memory session repository --- adapter/di/container.py | 51 ++++++++++++++++++++++++++++++----- repository/sandbox_session.py | 28 +++++++++++++++++++ tasks.md | 2 +- usecase/sandbox.py | 47 +++++++++++++++++++++++++++++++- 4 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 repository/sandbox_session.py diff --git a/adapter/di/container.py b/adapter/di/container.py index 8c08e7f..f091913 100644 --- a/adapter/di/container.py +++ b/adapter/di/container.py @@ -1,24 +1,34 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from pathlib import Path +import docker +from docker import DockerClient + from adapter.config.loader import load_config from adapter.config.model import AppConfig +from adapter.docker.runtime import DockerSandboxRuntime from adapter.observability.factory import build_observability from adapter.observability.runtime import ObservabilityRuntime from domain.user import User +from repository.sandbox_session import InMemorySandboxSessionRepository from repository.user import InMemoryUserRepository +from usecase.interface import Clock +from usecase.sandbox import CreateSandbox from usecase.user import GetUser @dataclass(frozen=True, slots=True) class AppRepositories: user: InMemoryUserRepository + sandbox_session: InMemorySandboxSessionRepository @dataclass(frozen=True, slots=True) class AppUsecases: get_user: GetUser + create_sandbox: CreateSandbox @dataclass(slots=True) @@ -27,16 +37,29 @@ class AppContainer: observability: ObservabilityRuntime repositories: AppRepositories usecases: AppUsecases + _docker_client: DockerClient = field(repr=False) _is_shutdown: bool = field(default=False, init=False, repr=False) def shutdown(self) -> None: if self._is_shutdown: return - try: - self.observability.shutdown() - finally: - self._is_shutdown = True + self._is_shutdown = True + errors: list[Exception] = [] + + for action in (self._docker_client.close, self.observability.shutdown): + try: + action() + except Exception as exc: + errors.append(exc) + + if errors: + raise ExceptionGroup('shutdown failed', errors) + + +class SystemClock(Clock): + def now(self) -> datetime: + return datetime.now(tz=UTC) def build_container( @@ -54,17 +77,32 @@ def build_container( ) observability = build_observability(app_config) + docker_client: DockerClient = docker.from_env() + clock = SystemClock() user_repository = InMemoryUserRepository( observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')] ) - repositories = AppRepositories(user=user_repository) + sandbox_repository = InMemorySandboxSessionRepository() + sandbox_runtime = DockerSandboxRuntime(app_config.sandbox, docker_client) + + repositories = AppRepositories( + user=user_repository, + sandbox_session=sandbox_repository, + ) usecases = AppUsecases( get_user=GetUser( repository=user_repository, logger=observability.logger, tracer=observability.tracer, - ) + ), + create_sandbox=CreateSandbox( + repository=sandbox_repository, + runtime=sandbox_runtime, + clock=clock, + logger=observability.logger, + ttl=timedelta(seconds=app_config.sandbox.ttl_seconds), + ), ) return AppContainer( @@ -72,4 +110,5 @@ def build_container( observability=observability, repositories=repositories, usecases=usecases, + _docker_client=docker_client, ) diff --git a/repository/sandbox_session.py b/repository/sandbox_session.py new file mode 100644 index 0000000..9b23cd7 --- /dev/null +++ b/repository/sandbox_session.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from domain.sandbox import SandboxSession +from usecase.interface import SandboxSessionRepository + + +class InMemorySandboxSessionRepository(SandboxSessionRepository): + def __init__(self) -> None: + self._sessions_by_chat_id: dict[str, SandboxSession] = {} + + def get_active_by_chat_id(self, chat_id: str) -> SandboxSession | None: + return self._sessions_by_chat_id.get(chat_id) + + def list_expired(self, now: datetime) -> list[SandboxSession]: + return [ + session + for session in self._sessions_by_chat_id.values() + if session.expires_at <= now + ] + + def save(self, session: SandboxSession) -> None: + self._sessions_by_chat_id[session.chat_id] = session + + def delete(self, session_id: str) -> None: + for chat_id, session in tuple(self._sessions_by_chat_id.items()): + if session.session_id == session_id: + del self._sessions_by_chat_id[chat_id] + return diff --git a/tasks.md b/tasks.md index 73c9b2e..ca244cb 100644 --- a/tasks.md +++ b/tasks.md @@ -64,7 +64,7 @@ ### M04. In-memory session repository и usecase `CreateSandbox` - Субагент: `feature-developer` -- Статус: pending +- Статус: completed - Зависимости: `M01`, `M02`, `M03` - Commit required: no - Scope: реализовать in-memory registry активных sandbox-сессий и usecase создания sandbox с логикой reuse по `chat_id` diff --git a/usecase/sandbox.py b/usecase/sandbox.py index 0c34422..452a5ff 100644 --- a/usecase/sandbox.py +++ b/usecase/sandbox.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import timedelta +from uuid import uuid4 from domain.sandbox import SandboxSession from usecase.interface import Clock, Logger, SandboxRuntime, SandboxSessionRepository @@ -26,7 +27,47 @@ class CreateSandbox: self._ttl = ttl def execute(self, command: CreateSandboxCommand) -> SandboxSession: - raise NotImplementedError + now = self._clock.now() + session = self._repository.get_active_by_chat_id(command.chat_id) + + if session is not None and session.expires_at > now: + self._logger.info( + 'sandbox_reused', + attrs={ + 'chat_id': command.chat_id, + 'session_id': session.session_id, + 'container_id': session.container_id, + }, + ) + return session + + if session is not None: + self._logger.info( + 'sandbox_replaced', + attrs={ + 'chat_id': command.chat_id, + 'session_id': session.session_id, + 'container_id': session.container_id, + }, + ) + self._runtime.stop(session.container_id) + self._repository.delete(session.session_id) + + new_session = self._runtime.create( + session_id=_new_session_id(), + chat_id=command.chat_id, + expires_at=now + self._ttl, + ) + self._repository.save(new_session) + self._logger.info( + 'sandbox_created', + attrs={ + 'chat_id': command.chat_id, + 'session_id': new_session.session_id, + 'container_id': new_session.container_id, + }, + ) + return new_session class CleanupExpiredSandboxes: @@ -44,3 +85,7 @@ class CleanupExpiredSandboxes: def execute(self) -> list[SandboxSession]: raise NotImplementedError + + +def _new_session_id() -> str: + return uuid4().hex