[feat] change str id type to UUID

This commit is contained in:
Azamat 2026-04-02 23:09:04 +03:00
parent e629e34c4d
commit 770af1fe76
11 changed files with 150 additions and 173 deletions

View file

@ -24,16 +24,13 @@ class DockerSandboxRuntime(SandboxRuntime):
def create( def create(
self, self,
*, *,
session_id: str, session_id: UUID,
chat_id: str, chat_id: UUID,
created_at: datetime, created_at: datetime,
expires_at: datetime, expires_at: datetime,
) -> SandboxSession: ) -> SandboxSession:
normalized_chat_id = chat_id
try: try:
normalized_chat_id = _canonical_chat_id(chat_id) chat_path = self._chat_path(chat_id)
chat_path = self._chat_path(normalized_chat_id)
dependencies_path = self._readonly_host_path( dependencies_path = self._readonly_host_path(
self._config.dependencies_host_path self._config.dependencies_host_path
) )
@ -44,19 +41,19 @@ class DockerSandboxRuntime(SandboxRuntime):
container = self._client.containers.run( container = self._client.containers.run(
self._config.image, self._config.image,
detach=True, detach=True,
labels=self._labels(session_id, normalized_chat_id, expires_at), labels=self._labels(session_id, chat_id, expires_at),
mounts=self._mounts(chat_path, dependencies_path, lambda_tools_path), mounts=self._mounts(chat_path, dependencies_path, lambda_tools_path),
) )
except (DockerException, OSError, ValueError) as exc: except (DockerException, OSError, ValueError) as exc:
raise SandboxStartError(normalized_chat_id) from exc raise SandboxStartError(str(chat_id)) from exc
container_id = str(getattr(container, 'id', '')).strip() container_id = str(getattr(container, 'id', '')).strip()
if not container_id: if not container_id:
raise SandboxStartError(normalized_chat_id) raise SandboxStartError(str(chat_id))
return SandboxSession( return SandboxSession(
session_id=session_id, session_id=session_id,
chat_id=normalized_chat_id, chat_id=chat_id,
container_id=container_id, container_id=container_id,
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
created_at=created_at, created_at=created_at,
@ -74,13 +71,13 @@ class DockerSandboxRuntime(SandboxRuntime):
def _labels( def _labels(
self, self,
session_id: str, session_id: UUID,
chat_id: str, chat_id: UUID,
expires_at: datetime, expires_at: datetime,
) -> dict[str, str]: ) -> dict[str, str]:
return { return {
'session_id': session_id, 'session_id': str(session_id),
'chat_id': chat_id, 'chat_id': str(chat_id),
'expires_at': expires_at.isoformat(), 'expires_at': expires_at.isoformat(),
} }
@ -110,12 +107,9 @@ class DockerSandboxRuntime(SandboxRuntime):
), ),
] ]
def _chat_path(self, chat_id: str) -> Path: def _chat_path(self, chat_id: UUID) -> Path:
if not chat_id.strip():
raise ValueError('invalid chat path')
chats_root = self._host_path(self._config.chats_root) chats_root = self._host_path(self._config.chats_root)
chat_path = (chats_root / chat_id).resolve(strict=False) chat_path = (chats_root / str(chat_id)).resolve(strict=False)
if not chat_path.is_relative_to(chats_root): if not chat_path.is_relative_to(chats_root):
raise ValueError('invalid chat path') raise ValueError('invalid chat path')
return chat_path return chat_path
@ -128,7 +122,3 @@ class DockerSandboxRuntime(SandboxRuntime):
def _host_path(self, path_value: str) -> Path: def _host_path(self, path_value: str) -> Path:
return Path(path_value).expanduser().resolve(strict=False) return Path(path_value).expanduser().resolve(strict=False)
def _canonical_chat_id(chat_id: str) -> str:
return str(UUID(str(chat_id).strip()))

View file

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict
class HealthResponse(BaseModel): class HealthResponse(BaseModel):
@ -11,19 +11,14 @@ class HealthResponse(BaseModel):
class CreateSandboxRequest(BaseModel): class CreateSandboxRequest(BaseModel):
model_config = ConfigDict(extra='forbid', str_strip_whitespace=True) model_config = ConfigDict(extra='forbid')
chat_id: str = Field(min_length=1) chat_id: UUID
@field_validator('chat_id')
@classmethod
def validate_chat_id(cls, value: str) -> str:
return str(UUID(value))
class SandboxSessionResponse(BaseModel): class SandboxSessionResponse(BaseModel):
session_id: str session_id: UUID
chat_id: str chat_id: UUID
container_id: str container_id: str
status: str status: str
expires_at: datetime expires_at: datetime

View file

@ -1,6 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from uuid import UUID
class SandboxStatus(str, Enum): class SandboxStatus(str, Enum):
@ -13,8 +14,8 @@ class SandboxStatus(str, Enum):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SandboxSession: class SandboxSession:
session_id: str session_id: UUID
chat_id: str chat_id: UUID
container_id: str container_id: str
status: SandboxStatus status: SandboxStatus
created_at: datetime created_at: datetime

View file

@ -1,6 +1,7 @@
import threading import threading
from types import TracebackType from types import TracebackType
from typing import Protocol from typing import Protocol
from uuid import UUID
from usecase.interface import LockContext, SandboxLifecycleLocker from usecase.interface import LockContext, SandboxLifecycleLocker
@ -31,9 +32,9 @@ class _ChatLock(LockContext):
class ProcessLocalSandboxLifecycleLocker(SandboxLifecycleLocker): class ProcessLocalSandboxLifecycleLocker(SandboxLifecycleLocker):
def __init__(self) -> None: def __init__(self) -> None:
self._registry_lock = threading.Lock() self._registry_lock = threading.Lock()
self._locks_by_chat_id: dict[str, _SyncLock] = {} self._locks_by_chat_id: dict[UUID, _SyncLock] = {}
def lock(self, chat_id: str) -> LockContext: def lock(self, chat_id: UUID) -> LockContext:
with self._registry_lock: with self._registry_lock:
lock = self._locks_by_chat_id.get(chat_id) lock = self._locks_by_chat_id.get(chat_id)
if lock is None: if lock is None:

View file

@ -1,5 +1,6 @@
import threading import threading
from datetime import datetime from datetime import datetime
from uuid import UUID
from domain.sandbox import SandboxSession from domain.sandbox import SandboxSession
from usecase.interface import SandboxSessionRepository from usecase.interface import SandboxSessionRepository
@ -7,10 +8,10 @@ from usecase.interface import SandboxSessionRepository
class InMemorySandboxSessionRepository(SandboxSessionRepository): class InMemorySandboxSessionRepository(SandboxSessionRepository):
def __init__(self) -> None: def __init__(self) -> None:
self._sessions_by_chat_id: dict[str, SandboxSession] = {} self._sessions_by_chat_id: dict[UUID, SandboxSession] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def get_active_by_chat_id(self, chat_id: str) -> SandboxSession | None: def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None:
with self._lock: with self._lock:
return self._sessions_by_chat_id.get(chat_id) return self._sessions_by_chat_id.get(chat_id)
@ -26,7 +27,7 @@ class InMemorySandboxSessionRepository(SandboxSessionRepository):
with self._lock: with self._lock:
self._sessions_by_chat_id[session.chat_id] = session self._sessions_by_chat_id[session.chat_id] = session
def delete(self, session_id: str) -> None: def delete(self, session_id: UUID) -> None:
with self._lock: with self._lock:
for chat_id, session in tuple(self._sessions_by_chat_id.items()): for chat_id, session in tuple(self._sessions_by_chat_id.items()):
if session.session_id == session_id: if session.session_id == session_id:

View file

@ -216,3 +216,14 @@
- Файлы: `repository/sandbox_lock.py`, при необходимости тесты в `test/*` - Файлы: `repository/sandbox_lock.py`, при необходимости тесты в `test/*`
- Решение: добавить eviction/ref-count/weakref policy во внешнем lock registry без нарушения сериализации lifecycle для активного `chat_id` - Решение: добавить eviction/ref-count/weakref policy во внешнем lock registry без нарушения сериализации lifecycle для активного `chat_id`
- Критерии приемки: registry locks не растет бесконечно без причины; сериализация для активных чатов сохраняется; поведение покрыто тестами - Критерии приемки: registry locks не растет бесконечно без причины; сериализация для активных чатов сохраняется; поведение покрыто тестами
### M18. Перевести sandbox ids на UUID types
- Субагент: `feature-developer`
- Статус: completed
- Зависимости: `M15`
- Commit required: no
- Scope: сделать `chat_id` и `session_id` типом `UUID` внутри sandbox scope, оставив `container_id` строкой как внешний Docker identifier
- Файлы: `domain/sandbox.py`, `usecase/interface.py`, `usecase/sandbox.py`, `repository/sandbox_session.py`, `adapter/http/fastapi/*`, `adapter/docker/runtime.py`, `adapter/di/container.py`, `test/*`
- Решение: HTTP boundary принимает/возвращает UUID, usecase и repository работают с UUID objects, Docker labels продолжают сериализоваться в строки через `str(uuid)`
- Критерии приемки: внутри sandbox flow `chat_id` и `session_id` больше не строки; `container_id` остается `str`; pydantic корректно сериализует UUID в response; `make pre-commit` проходит

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID
from docker import DockerClient from docker import DockerClient
from fastapi import FastAPI from fastapi import FastAPI
@ -28,8 +29,9 @@ from repository.sandbox_session import InMemorySandboxSessionRepository
from usecase.interface import Attrs from usecase.interface import Attrs
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
CHAT_ID = '123e4567-e89b-12d3-a456-426614174000' CHAT_ID = UUID('123e4567-e89b-12d3-a456-426614174000')
NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000' NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000'
SESSION_ID = UUID('00000000-0000-0000-0000-000000000011')
class FakeLogger: class FakeLogger:
@ -253,7 +255,7 @@ def test_post_create_returns_session_with_canonical_chat_id(monkeypatch) -> None
config = build_config() config = build_config()
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC) expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
session = SandboxSession( session = SandboxSession(
session_id='session-123', session_id=SESSION_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-123', container_id='container-123',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -284,8 +286,8 @@ def test_post_create_returns_session_with_canonical_chat_id(monkeypatch) -> None
assert status_code == 200 assert status_code == 200
assert response == { assert response == {
'session_id': 'session-123', 'session_id': str(SESSION_ID),
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'container_id': 'container-123', 'container_id': 'container-123',
'status': 'running', 'status': 'running',
'expires_at': '2026-04-02T12:05:00Z', 'expires_at': '2026-04-02T12:05:00Z',
@ -306,7 +308,7 @@ def test_post_create_rejects_non_uuid_chat_id(monkeypatch) -> None:
config = build_config() config = build_config()
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC) expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
session = SandboxSession( session = SandboxSession(
session_id='session-123', session_id=SESSION_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-123', container_id='container-123',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -344,7 +346,7 @@ def test_post_create_rejects_non_uuid_chat_id(monkeypatch) -> None:
def test_post_create_maps_start_errors_to_service_unavailable(monkeypatch) -> None: def test_post_create_maps_start_errors_to_service_unavailable(monkeypatch) -> None:
config = build_config() config = build_config()
logger = FakeLogger() logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(error=SandboxStartError(CHAT_ID)) create_usecase = FakeCreateSandboxUsecase(error=SandboxStartError(str(CHAT_ID)))
cleanup_usecase = FakeCleanupExpiredSandboxes() cleanup_usecase = FakeCleanupExpiredSandboxes()
docker_client = FakeDockerClient() docker_client = FakeDockerClient()
container = build_container( container = build_container(
@ -362,7 +364,7 @@ def test_post_create_maps_start_errors_to_service_unavailable(monkeypatch) -> No
app = app_module.create_app(config=config) app = app_module.create_app(config=config)
status_code, response = asyncio.run( status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': CHAT_ID}) exercise_create_request(app, {'chat_id': str(CHAT_ID)})
) )
assert status_code == 503 assert status_code == 503
@ -391,7 +393,7 @@ def test_post_create_maps_generic_sandbox_errors_to_internal_error(monkeypatch)
app = app_module.create_app(config=config) app = app_module.create_app(config=config)
status_code, response = asyncio.run( status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': CHAT_ID}) exercise_create_request(app, {'chat_id': str(CHAT_ID)})
) )
assert status_code == 500 assert status_code == 500
@ -403,7 +405,7 @@ def test_removed_user_endpoint_returns_not_found(monkeypatch) -> None:
config = build_config() config = build_config()
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC) expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
session = SandboxSession( session = SandboxSession(
session_id='session-123', session_id=SESSION_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-123', container_id='container-123',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,

View file

@ -1,6 +1,7 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, TypedDict from typing import Any, TypedDict
from uuid import UUID
import pytest import pytest
from docker import DockerClient from docker import DockerClient
@ -12,8 +13,9 @@ from adapter.docker.runtime import DockerSandboxRuntime
from domain.error import SandboxError, SandboxStartError from domain.error import SandboxError, SandboxStartError
from domain.sandbox import SandboxStatus from domain.sandbox import SandboxStatus
CHAT_ID = '123e4567-e89b-12d3-a456-426614174000' CHAT_ID = UUID('123e4567-e89b-12d3-a456-426614174000')
NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000' NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000'
SESSION_ID = UUID('00000000-0000-0000-0000-000000000010')
class FakeContainer: class FakeContainer:
@ -107,26 +109,26 @@ def test_runtime_create_applies_mount_policy_and_labels_with_canonical_chat_id(
expires_at = created_at + timedelta(minutes=5) expires_at = created_at + timedelta(minutes=5)
session = runtime.create( session = runtime.create(
session_id='session-123', session_id=SESSION_ID,
chat_id=NON_CANONICAL_CHAT_ID, chat_id=UUID(NON_CANONICAL_CHAT_ID),
created_at=created_at, created_at=created_at,
expires_at=expires_at, expires_at=expires_at,
) )
assert session.session_id == 'session-123' assert session.session_id == SESSION_ID
assert session.chat_id == CHAT_ID assert session.chat_id == CHAT_ID
assert session.container_id == 'container-123' assert session.container_id == 'container-123'
assert session.status is SandboxStatus.RUNNING assert session.status is SandboxStatus.RUNNING
assert session.created_at == created_at assert session.created_at == created_at
assert session.expires_at == expires_at assert session.expires_at == expires_at
assert (tmp_path / 'chats' / CHAT_ID).is_dir() assert (tmp_path / 'chats' / str(CHAT_ID)).is_dir()
call = containers.run_calls[0] call = containers.run_calls[0]
assert call['args'] == ('sandbox:latest',) assert call['args'] == ('sandbox:latest',)
assert call['kwargs']['detach'] is True assert call['kwargs']['detach'] is True
assert call['kwargs']['labels'] == { assert call['kwargs']['labels'] == {
'session_id': 'session-123', 'session_id': str(SESSION_ID),
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'expires_at': expires_at.isoformat(), 'expires_at': expires_at.isoformat(),
} }
@ -134,7 +136,7 @@ def test_runtime_create_applies_mount_policy_and_labels_with_canonical_chat_id(
assert [dict(mount) for mount in mounts] == [ assert [dict(mount) for mount in mounts] == [
{ {
'Target': '/workspace/chat', 'Target': '/workspace/chat',
'Source': str((tmp_path / 'chats' / CHAT_ID).resolve(strict=False)), 'Source': str((tmp_path / 'chats' / str(CHAT_ID)).resolve(strict=False)),
'Type': 'bind', 'Type': 'bind',
'ReadOnly': False, 'ReadOnly': False,
}, },
@ -164,14 +166,14 @@ def test_runtime_create_raises_start_error_when_container_id_is_missing(
with pytest.raises(SandboxStartError) as excinfo: with pytest.raises(SandboxStartError) as excinfo:
runtime.create( runtime.create(
session_id='session-123', session_id=SESSION_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC), created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC), expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
) )
assert str(excinfo.value) == 'sandbox_start_failed' assert str(excinfo.value) == 'sandbox_start_failed'
assert excinfo.value.chat_id == CHAT_ID assert excinfo.value.chat_id == str(CHAT_ID)
def test_runtime_stop_ignores_missing_container(tmp_path: Path) -> None: def test_runtime_stop_ignores_missing_container(tmp_path: Path) -> None:
@ -195,24 +197,3 @@ def test_runtime_stop_wraps_docker_errors(tmp_path: Path) -> None:
runtime.stop('container-123') runtime.stop('container-123')
assert str(excinfo.value) == 'sandbox_stop_failed' assert str(excinfo.value) == 'sandbox_stop_failed'
@pytest.mark.parametrize('chat_id', ['.', 'a/..', 'x/../y'])
def test_runtime_create_rejects_non_uuid_chat_id(tmp_path: Path, chat_id: str) -> None:
config = build_config(tmp_path)
(tmp_path / 'dependencies').mkdir()
(tmp_path / 'lambda-tools').mkdir()
containers = FakeContainers()
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
with pytest.raises(SandboxStartError) as excinfo:
runtime.create(
session_id='session-123',
chat_id=chat_id,
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
)
assert str(excinfo.value) == 'sandbox_start_failed'
assert excinfo.value.chat_id == chat_id
assert containers.run_calls == []

View file

@ -1,18 +1,28 @@
import threading import threading
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID
from domain.sandbox import SandboxSession, SandboxStatus from domain.sandbox import SandboxSession, SandboxStatus
from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
from repository.sandbox_session import InMemorySandboxSessionRepository from repository.sandbox_session import InMemorySandboxSessionRepository
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
CHAT_ID = '11111111-1111-1111-1111-111111111111' CHAT_ID = UUID('11111111-1111-1111-1111-111111111111')
NON_CANONICAL_CHAT_ID = '11111111111111111111111111111111' NON_CANONICAL_CHAT_ID = '11111111111111111111111111111111'
EXPIRED_CHAT_ID = '22222222-2222-2222-2222-222222222222' EXPIRED_CHAT_ID = UUID('22222222-2222-2222-2222-222222222222')
BOUNDARY_CHAT_ID = '33333333-3333-3333-3333-333333333333' BOUNDARY_CHAT_ID = UUID('33333333-3333-3333-3333-333333333333')
ACTIVE_CHAT_ID = '44444444-4444-4444-4444-444444444444' ACTIVE_CHAT_ID = UUID('44444444-4444-4444-4444-444444444444')
FAIL_CHAT_ID = '55555555-5555-5555-5555-555555555555' FAIL_CHAT_ID = UUID('55555555-5555-5555-5555-555555555555')
CLEAN_CHAT_ID = '66666666-6666-6666-6666-666666666666' CLEAN_CHAT_ID = UUID('66666666-6666-6666-6666-666666666666')
SESSION_REUSED_ID = UUID('00000000-0000-0000-0000-000000000001')
SESSION_OLD_ID = UUID('00000000-0000-0000-0000-000000000002')
SESSION_NEW_ID = UUID('00000000-0000-0000-0000-000000000003')
SESSION_EXPIRED_ID = UUID('00000000-0000-0000-0000-000000000004')
SESSION_BOUNDARY_ID = UUID('00000000-0000-0000-0000-000000000005')
SESSION_ACTIVE_ID = UUID('00000000-0000-0000-0000-000000000006')
SESSION_FAIL_ID = UUID('00000000-0000-0000-0000-000000000007')
SESSION_CLEAN_ID = UUID('00000000-0000-0000-0000-000000000008')
SESSION_REPLACEMENT_ID = UUID('00000000-0000-0000-0000-000000000009')
class FakeClock: class FakeClock:
@ -52,9 +62,9 @@ class FakeLockContext:
class FakeLocker: class FakeLocker:
def __init__(self) -> None: def __init__(self) -> None:
self.chat_ids: list[str] = [] self.chat_ids: list[UUID] = []
def lock(self, chat_id: str) -> FakeLockContext: def lock(self, chat_id: UUID) -> FakeLockContext:
self.chat_ids.append(chat_id) self.chat_ids.append(chat_id)
return FakeLockContext() return FakeLockContext()
@ -63,7 +73,7 @@ class TrackingLockContext:
def __init__( def __init__(
self, self,
locker: 'TrackingLocker', locker: 'TrackingLocker',
chat_id: str, chat_id: UUID,
inner_context, inner_context,
) -> None: ) -> None:
self._locker = locker self._locker = locker
@ -89,9 +99,9 @@ class TrackingLocker:
self._state_lock = threading.Lock() self._state_lock = threading.Lock()
self._attempts = 0 self._attempts = 0
self.second_attempted = threading.Event() self.second_attempted = threading.Event()
self.chat_ids: list[str] = [] self.chat_ids: list[UUID] = []
def lock(self, chat_id: str) -> TrackingLockContext: def lock(self, chat_id: UUID) -> TrackingLockContext:
return TrackingLockContext(self, chat_id, self._locker.lock(chat_id)) return TrackingLockContext(self, chat_id, self._locker.lock(chat_id))
@ -105,8 +115,8 @@ class BlockingCreateRuntime:
def create( def create(
self, self,
*, *,
session_id: str, session_id: UUID,
chat_id: str, chat_id: UUID,
created_at: datetime, created_at: datetime,
expires_at: datetime, expires_at: datetime,
) -> SandboxSession: ) -> SandboxSession:
@ -150,8 +160,8 @@ class FakeRuntime:
def create( def create(
self, self,
*, *,
session_id: str, session_id: UUID,
chat_id: str, chat_id: UUID,
created_at: datetime, created_at: datetime,
expires_at: datetime, expires_at: datetime,
) -> SandboxSession: ) -> SandboxSession:
@ -190,7 +200,7 @@ class FailingStopRuntime(FakeRuntime):
def test_create_sandbox_reuses_active_session_when_not_expired() -> None: def test_create_sandbox_reuses_active_session_when_not_expired() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC) now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
session = SandboxSession( session = SandboxSession(
session_id='session-1', session_id=SESSION_REUSED_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-1', container_id='container-1',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -223,8 +233,8 @@ def test_create_sandbox_reuses_active_session_when_not_expired() -> None:
'info', 'info',
'sandbox_reused', 'sandbox_reused',
{ {
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'session_id': 'session-1', 'session_id': str(SESSION_REUSED_ID),
'container_id': 'container-1', 'container_id': 'container-1',
}, },
) )
@ -236,7 +246,7 @@ def test_create_sandbox_replaces_expired_session_and_creates_new_one(
) -> None: ) -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC) now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_session = SandboxSession( expired_session = SandboxSession(
session_id='session-old', session_id=SESSION_OLD_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-old', container_id='container-old',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -256,23 +266,23 @@ def test_create_sandbox_replaces_expired_session_and_creates_new_one(
logger=logger, logger=logger,
ttl=timedelta(minutes=5), ttl=timedelta(minutes=5),
) )
monkeypatch.setattr('usecase.sandbox._new_session_id', lambda: 'session-new') monkeypatch.setattr('usecase.sandbox._new_session_id', lambda: SESSION_NEW_ID)
result = usecase.execute(CreateSandboxCommand(chat_id=CHAT_ID)) result = usecase.execute(CreateSandboxCommand(chat_id=CHAT_ID))
assert runtime.stop_calls == ['container-old'] assert runtime.stop_calls == ['container-old']
assert runtime.create_calls == [ assert runtime.create_calls == [
{ {
'session_id': 'session-new', 'session_id': SESSION_NEW_ID,
'chat_id': CHAT_ID, 'chat_id': CHAT_ID,
'created_at': now, 'created_at': now,
'expires_at': now + timedelta(minutes=5), 'expires_at': now + timedelta(minutes=5),
} }
] ]
assert result == SandboxSession( assert result == SandboxSession(
session_id='session-new', session_id=SESSION_NEW_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-session-new', container_id=f'container-{SESSION_NEW_ID}',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
created_at=now, created_at=now,
expires_at=now + timedelta(minutes=5), expires_at=now + timedelta(minutes=5),
@ -284,8 +294,8 @@ def test_create_sandbox_replaces_expired_session_and_creates_new_one(
'info', 'info',
'sandbox_replaced', 'sandbox_replaced',
{ {
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'session_id': 'session-old', 'session_id': str(SESSION_OLD_ID),
'container_id': 'container-old', 'container_id': 'container-old',
}, },
), ),
@ -293,9 +303,9 @@ def test_create_sandbox_replaces_expired_session_and_creates_new_one(
'info', 'info',
'sandbox_created', 'sandbox_created',
{ {
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'session_id': 'session-new', 'session_id': str(SESSION_NEW_ID),
'container_id': 'container-session-new', 'container_id': f'container-{SESSION_NEW_ID}',
}, },
), ),
] ]
@ -316,7 +326,7 @@ def test_create_sandbox_creates_new_session_when_none_exists() -> None:
ttl=timedelta(minutes=5), ttl=timedelta(minutes=5),
) )
result = usecase.execute(CreateSandboxCommand(chat_id=NON_CANONICAL_CHAT_ID)) result = usecase.execute(CreateSandboxCommand(chat_id=UUID(NON_CANONICAL_CHAT_ID)))
assert result.chat_id == CHAT_ID assert result.chat_id == CHAT_ID
assert result.container_id == f'container-{result.session_id}' assert result.container_id == f'container-{result.session_id}'
@ -332,15 +342,14 @@ def test_create_sandbox_creates_new_session_when_none_exists() -> None:
} }
assert runtime.stop_calls == [] assert runtime.stop_calls == []
assert repository.get_active_by_chat_id(CHAT_ID) == result assert repository.get_active_by_chat_id(CHAT_ID) == result
assert repository.get_active_by_chat_id(NON_CANONICAL_CHAT_ID) is None
assert locker.chat_ids == [CHAT_ID] assert locker.chat_ids == [CHAT_ID]
assert logger.messages == [ assert logger.messages == [
( (
'info', 'info',
'sandbox_created', 'sandbox_created',
{ {
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'session_id': result.session_id, 'session_id': str(result.session_id),
'container_id': result.container_id, 'container_id': result.container_id,
}, },
) )
@ -363,7 +372,7 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
logger=logger, logger=logger,
ttl=timedelta(minutes=5), ttl=timedelta(minutes=5),
) )
monkeypatch.setattr('usecase.sandbox._new_session_id', lambda: 'session-new') monkeypatch.setattr('usecase.sandbox._new_session_id', lambda: SESSION_NEW_ID)
results: list[SandboxSession | None] = [None, None] results: list[SandboxSession | None] = [None, None]
errors: list[Exception] = [] errors: list[Exception] = []
@ -392,9 +401,9 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
assert errors == [] assert errors == []
assert results[0] == results[1] assert results[0] == results[1]
assert results[0] == SandboxSession( assert results[0] == SandboxSession(
session_id='session-new', session_id=SESSION_NEW_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-session-new', container_id=f'container-{SESSION_NEW_ID}',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
created_at=now, created_at=now,
expires_at=now + timedelta(minutes=5), expires_at=now + timedelta(minutes=5),
@ -408,18 +417,18 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
'info', 'info',
'sandbox_created', 'sandbox_created',
{ {
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'session_id': 'session-new', 'session_id': str(SESSION_NEW_ID),
'container_id': 'container-session-new', 'container_id': f'container-{SESSION_NEW_ID}',
}, },
), ),
( (
'info', 'info',
'sandbox_reused', 'sandbox_reused',
{ {
'chat_id': CHAT_ID, 'chat_id': str(CHAT_ID),
'session_id': 'session-new', 'session_id': str(SESSION_NEW_ID),
'container_id': 'container-session-new', 'container_id': f'container-{SESSION_NEW_ID}',
}, },
), ),
] ]
@ -428,7 +437,7 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() -> None: def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC) now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_session = SandboxSession( expired_session = SandboxSession(
session_id='session-expired', session_id=SESSION_EXPIRED_ID,
chat_id=EXPIRED_CHAT_ID, chat_id=EXPIRED_CHAT_ID,
container_id='container-expired', container_id='container-expired',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -436,7 +445,7 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
expires_at=now - timedelta(seconds=1), expires_at=now - timedelta(seconds=1),
) )
boundary_session = SandboxSession( boundary_session = SandboxSession(
session_id='session-boundary', session_id=SESSION_BOUNDARY_ID,
chat_id=BOUNDARY_CHAT_ID, chat_id=BOUNDARY_CHAT_ID,
container_id='container-boundary', container_id='container-boundary',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -444,7 +453,7 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
expires_at=now, expires_at=now,
) )
active_session = SandboxSession( active_session = SandboxSession(
session_id='session-active', session_id=SESSION_ACTIVE_ID,
chat_id=ACTIVE_CHAT_ID, chat_id=ACTIVE_CHAT_ID,
container_id='container-active', container_id='container-active',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -479,8 +488,8 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
'info', 'info',
'sandbox_cleaned', 'sandbox_cleaned',
{ {
'chat_id': EXPIRED_CHAT_ID, 'chat_id': str(EXPIRED_CHAT_ID),
'session_id': 'session-expired', 'session_id': str(SESSION_EXPIRED_ID),
'container_id': 'container-expired', 'container_id': 'container-expired',
}, },
), ),
@ -488,8 +497,8 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
'info', 'info',
'sandbox_cleaned', 'sandbox_cleaned',
{ {
'chat_id': BOUNDARY_CHAT_ID, 'chat_id': str(BOUNDARY_CHAT_ID),
'session_id': 'session-boundary', 'session_id': str(SESSION_BOUNDARY_ID),
'container_id': 'container-boundary', 'container_id': 'container-boundary',
}, },
), ),
@ -499,7 +508,7 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
def test_cleanup_expired_sandboxes_skips_replaced_session_from_stale_snapshot() -> None: def test_cleanup_expired_sandboxes_skips_replaced_session_from_stale_snapshot() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC) now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_snapshot = SandboxSession( expired_snapshot = SandboxSession(
session_id='session-expired', session_id=SESSION_EXPIRED_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-expired', container_id='container-expired',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -507,7 +516,7 @@ def test_cleanup_expired_sandboxes_skips_replaced_session_from_stale_snapshot()
expires_at=now - timedelta(seconds=1), expires_at=now - timedelta(seconds=1),
) )
replacement_session = SandboxSession( replacement_session = SandboxSession(
session_id='session-new', session_id=SESSION_REPLACEMENT_ID,
chat_id=CHAT_ID, chat_id=CHAT_ID,
container_id='container-new', container_id='container-new',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -539,7 +548,7 @@ def test_cleanup_expired_sandboxes_skips_replaced_session_from_stale_snapshot()
def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None: def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC) now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
failing_session = SandboxSession( failing_session = SandboxSession(
session_id='session-fail', session_id=SESSION_FAIL_ID,
chat_id=FAIL_CHAT_ID, chat_id=FAIL_CHAT_ID,
container_id='container-fail', container_id='container-fail',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -547,7 +556,7 @@ def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
expires_at=now - timedelta(minutes=1), expires_at=now - timedelta(minutes=1),
) )
cleaned_session = SandboxSession( cleaned_session = SandboxSession(
session_id='session-clean', session_id=SESSION_CLEAN_ID,
chat_id=CLEAN_CHAT_ID, chat_id=CLEAN_CHAT_ID,
container_id='container-clean', container_id='container-clean',
status=SandboxStatus.RUNNING, status=SandboxStatus.RUNNING,
@ -580,8 +589,8 @@ def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
'error', 'error',
'sandbox_clean_failed', 'sandbox_clean_failed',
{ {
'chat_id': FAIL_CHAT_ID, 'chat_id': str(FAIL_CHAT_ID),
'session_id': 'session-fail', 'session_id': str(SESSION_FAIL_ID),
'container_id': 'container-fail', 'container_id': 'container-fail',
'error': 'RuntimeError', 'error': 'RuntimeError',
}, },
@ -590,8 +599,8 @@ def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
'info', 'info',
'sandbox_cleaned', 'sandbox_cleaned',
{ {
'chat_id': CLEAN_CHAT_ID, 'chat_id': str(CLEAN_CHAT_ID),
'session_id': 'session-clean', 'session_id': str(SESSION_CLEAN_ID),
'container_id': 'container-clean', 'container_id': 'container-clean',
}, },
), ),

View file

@ -2,6 +2,7 @@ from collections.abc import Mapping
from datetime import datetime from datetime import datetime
from types import TracebackType from types import TracebackType
from typing import Protocol, TypeAlias from typing import Protocol, TypeAlias
from uuid import UUID
from domain.sandbox import SandboxSession from domain.sandbox import SandboxSession
from domain.user import User from domain.user import User
@ -19,13 +20,13 @@ class UserRepository(Protocol):
class SandboxSessionRepository(Protocol): class SandboxSessionRepository(Protocol):
def get_active_by_chat_id(self, chat_id: str) -> SandboxSession | None: ... def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None: ...
def list_expired(self, now: datetime) -> list[SandboxSession]: ... def list_expired(self, now: datetime) -> list[SandboxSession]: ...
def save(self, session: SandboxSession) -> None: ... def save(self, session: SandboxSession) -> None: ...
def delete(self, session_id: str) -> None: ... def delete(self, session_id: UUID) -> None: ...
class LockContext(Protocol): class LockContext(Protocol):
@ -40,15 +41,15 @@ class LockContext(Protocol):
class SandboxLifecycleLocker(Protocol): class SandboxLifecycleLocker(Protocol):
def lock(self, chat_id: str) -> LockContext: ... def lock(self, chat_id: UUID) -> LockContext: ...
class SandboxRuntime(Protocol): class SandboxRuntime(Protocol):
def create( def create(
self, self,
*, *,
session_id: str, session_id: UUID,
chat_id: str, chat_id: UUID,
created_at: datetime, created_at: datetime,
expires_at: datetime, expires_at: datetime,
) -> SandboxSession: ... ) -> SandboxSession: ...

View file

@ -14,7 +14,7 @@ from usecase.interface import (
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CreateSandboxCommand: class CreateSandboxCommand:
chat_id: str chat_id: UUID
class CreateSandbox: class CreateSandbox:
@ -35,7 +35,7 @@ class CreateSandbox:
self._ttl = ttl self._ttl = ttl
def execute(self, command: CreateSandboxCommand) -> SandboxSession: def execute(self, command: CreateSandboxCommand) -> SandboxSession:
chat_id = _canonical_chat_id(command.chat_id) chat_id = command.chat_id
with self._locker.lock(chat_id): with self._locker.lock(chat_id):
session = self._repository.get_active_by_chat_id(chat_id) session = self._repository.get_active_by_chat_id(chat_id)
@ -44,22 +44,14 @@ class CreateSandbox:
if session is not None and session.expires_at > now: if session is not None and session.expires_at > now:
self._logger.info( self._logger.info(
'sandbox_reused', 'sandbox_reused',
attrs={ attrs=_sandbox_attrs(session),
'chat_id': chat_id,
'session_id': session.session_id,
'container_id': session.container_id,
},
) )
return session return session
if session is not None: if session is not None:
self._logger.info( self._logger.info(
'sandbox_replaced', 'sandbox_replaced',
attrs={ attrs=_sandbox_attrs(session),
'chat_id': chat_id,
'session_id': session.session_id,
'container_id': session.container_id,
},
) )
self._runtime.stop(session.container_id) self._runtime.stop(session.container_id)
self._repository.delete(session.session_id) self._repository.delete(session.session_id)
@ -75,11 +67,7 @@ class CreateSandbox:
self._repository.save(new_session) self._repository.save(new_session)
self._logger.info( self._logger.info(
'sandbox_created', 'sandbox_created',
attrs={ attrs=_sandbox_attrs(new_session),
'chat_id': chat_id,
'session_id': new_session.session_id,
'container_id': new_session.container_id,
},
) )
return new_session return new_session
@ -107,14 +95,11 @@ class CleanupExpiredSandboxes:
try: try:
cleaned_session = self._cleanup_session(session) cleaned_session = self._cleanup_session(session)
except Exception as exc: except Exception as exc:
attrs = _sandbox_attrs(session)
attrs['error'] = type(exc).__name__
self._logger.error( self._logger.error(
'sandbox_clean_failed', 'sandbox_clean_failed',
attrs={ attrs=attrs,
'chat_id': session.chat_id,
'session_id': session.session_id,
'container_id': session.container_id,
'error': type(exc).__name__,
},
) )
continue continue
@ -124,11 +109,7 @@ class CleanupExpiredSandboxes:
cleaned_sessions.append(cleaned_session) cleaned_sessions.append(cleaned_session)
self._logger.info( self._logger.info(
'sandbox_cleaned', 'sandbox_cleaned',
attrs={ attrs=_sandbox_attrs(cleaned_session),
'chat_id': cleaned_session.chat_id,
'session_id': cleaned_session.session_id,
'container_id': cleaned_session.container_id,
},
) )
return cleaned_sessions return cleaned_sessions
@ -151,9 +132,13 @@ class CleanupExpiredSandboxes:
return current_session return current_session
def _new_session_id() -> str: def _new_session_id() -> UUID:
return uuid4().hex return uuid4()
def _canonical_chat_id(chat_id: str) -> str: def _sandbox_attrs(session: SandboxSession) -> dict[str, str]:
return str(UUID(str(chat_id).strip())) return {
'chat_id': str(session.chat_id),
'session_id': str(session.session_id),
'container_id': session.container_id,
}