ref #6: [feat] add impl in memory session repository

This commit is contained in:
Azamat 2026-04-02 13:12:34 +03:00
parent 87c789b7fe
commit 33ebcb1a82
4 changed files with 120 additions and 8 deletions

View file

@ -1,24 +1,34 @@
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
import docker
from docker import DockerClient
from adapter.config.loader import load_config from adapter.config.loader import load_config
from adapter.config.model import AppConfig from adapter.config.model import AppConfig
from adapter.docker.runtime import DockerSandboxRuntime
from adapter.observability.factory import build_observability from adapter.observability.factory import build_observability
from adapter.observability.runtime import ObservabilityRuntime from adapter.observability.runtime import ObservabilityRuntime
from domain.user import User from domain.user import User
from repository.sandbox_session import InMemorySandboxSessionRepository
from repository.user import InMemoryUserRepository from repository.user import InMemoryUserRepository
from usecase.interface import Clock
from usecase.sandbox import CreateSandbox
from usecase.user import GetUser from usecase.user import GetUser
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AppRepositories: class AppRepositories:
user: InMemoryUserRepository user: InMemoryUserRepository
sandbox_session: InMemorySandboxSessionRepository
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AppUsecases: class AppUsecases:
get_user: GetUser get_user: GetUser
create_sandbox: CreateSandbox
@dataclass(slots=True) @dataclass(slots=True)
@ -27,16 +37,29 @@ class AppContainer:
observability: ObservabilityRuntime observability: ObservabilityRuntime
repositories: AppRepositories repositories: AppRepositories
usecases: AppUsecases usecases: AppUsecases
_docker_client: DockerClient = field(repr=False)
_is_shutdown: bool = field(default=False, init=False, repr=False) _is_shutdown: bool = field(default=False, init=False, repr=False)
def shutdown(self) -> None: def shutdown(self) -> None:
if self._is_shutdown: if self._is_shutdown:
return return
try: self._is_shutdown = True
self.observability.shutdown() errors: list[Exception] = []
finally:
self._is_shutdown = True 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( def build_container(
@ -54,17 +77,32 @@ def build_container(
) )
observability = build_observability(app_config) observability = build_observability(app_config)
docker_client: DockerClient = docker.from_env()
clock = SystemClock()
user_repository = InMemoryUserRepository( user_repository = InMemoryUserRepository(
observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')] 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( usecases = AppUsecases(
get_user=GetUser( get_user=GetUser(
repository=user_repository, repository=user_repository,
logger=observability.logger, logger=observability.logger,
tracer=observability.tracer, 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( return AppContainer(
@ -72,4 +110,5 @@ def build_container(
observability=observability, observability=observability,
repositories=repositories, repositories=repositories,
usecases=usecases, usecases=usecases,
_docker_client=docker_client,
) )

View file

@ -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

View file

@ -64,7 +64,7 @@
### M04. In-memory session repository и usecase `CreateSandbox` ### M04. In-memory session repository и usecase `CreateSandbox`
- Субагент: `feature-developer` - Субагент: `feature-developer`
- Статус: pending - Статус: completed
- Зависимости: `M01`, `M02`, `M03` - Зависимости: `M01`, `M02`, `M03`
- Commit required: no - Commit required: no
- Scope: реализовать in-memory registry активных sandbox-сессий и usecase создания sandbox с логикой reuse по `chat_id` - Scope: реализовать in-memory registry активных sandbox-сессий и usecase создания sandbox с логикой reuse по `chat_id`

View file

@ -1,5 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from uuid import uuid4
from domain.sandbox import SandboxSession from domain.sandbox import SandboxSession
from usecase.interface import Clock, Logger, SandboxRuntime, SandboxSessionRepository from usecase.interface import Clock, Logger, SandboxRuntime, SandboxSessionRepository
@ -26,7 +27,47 @@ class CreateSandbox:
self._ttl = ttl self._ttl = ttl
def execute(self, command: CreateSandboxCommand) -> SandboxSession: 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: class CleanupExpiredSandboxes:
@ -44,3 +85,7 @@ class CleanupExpiredSandboxes:
def execute(self) -> list[SandboxSession]: def execute(self) -> list[SandboxSession]:
raise NotImplementedError raise NotImplementedError
def _new_session_id() -> str:
return uuid4().hex