[fix] restart gap
This commit is contained in:
parent
770af1fe76
commit
50af62b3fb
10 changed files with 348 additions and 4 deletions
|
|
@ -23,8 +23,10 @@ from adapter.di.container import AppContainer, AppRepositories, AppUsecases
|
|||
from adapter.http.fastapi import app as app_module
|
||||
from adapter.observability.noop import NoopMetrics, NoopTracer
|
||||
from adapter.observability.runtime import ObservabilityRuntime
|
||||
from adapter.sandbox.reconciliation import SandboxSessionReconciler
|
||||
from domain.error import SandboxError, SandboxStartError
|
||||
from domain.sandbox import SandboxSession, SandboxStatus
|
||||
from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
|
||||
from repository.sandbox_session import InMemorySandboxSessionRepository
|
||||
from usecase.interface import Attrs
|
||||
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
|
||||
|
|
@ -85,6 +87,61 @@ class FakeDockerClient(DockerClient):
|
|||
self.close_calls += 1
|
||||
|
||||
|
||||
class EmptySandboxState:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def list_active_sessions(self) -> list[SandboxSession]:
|
||||
self.calls += 1
|
||||
return []
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self, now: datetime) -> None:
|
||||
self._now = now
|
||||
|
||||
def now(self) -> datetime:
|
||||
return self._now
|
||||
|
||||
|
||||
class FakeLifecycleRuntime:
|
||||
def __init__(self, sessions: list[SandboxSession]) -> None:
|
||||
self._sessions = list(sessions)
|
||||
self.list_calls = 0
|
||||
self.create_calls: list[CreateSandboxCommand] = []
|
||||
self.stop_calls: list[str] = []
|
||||
|
||||
def list_active_sessions(self) -> list[SandboxSession]:
|
||||
self.list_calls += 1
|
||||
return list(self._sessions)
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
session_id: UUID,
|
||||
chat_id: UUID,
|
||||
created_at: datetime,
|
||||
expires_at: datetime,
|
||||
) -> SandboxSession:
|
||||
self.create_calls.append(CreateSandboxCommand(chat_id=chat_id))
|
||||
session = SandboxSession(
|
||||
session_id=session_id,
|
||||
chat_id=chat_id,
|
||||
container_id=f'container-{session_id}',
|
||||
status=SandboxStatus.RUNNING,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self._sessions = [
|
||||
existing for existing in self._sessions if existing.chat_id != chat_id
|
||||
]
|
||||
self._sessions.append(session)
|
||||
return session
|
||||
|
||||
def stop(self, container_id: str) -> None:
|
||||
self.stop_calls.append(container_id)
|
||||
|
||||
|
||||
def build_config() -> AppConfig:
|
||||
return AppConfig(
|
||||
app=AppSectionConfig(name='master', env='test'),
|
||||
|
|
@ -123,10 +180,11 @@ def build_config() -> AppConfig:
|
|||
|
||||
def build_container(
|
||||
config: AppConfig,
|
||||
create_sandbox_usecase: FakeCreateSandboxUsecase,
|
||||
cleanup_usecase: FakeCleanupExpiredSandboxes,
|
||||
create_sandbox_usecase: CreateSandbox,
|
||||
cleanup_usecase: CleanupExpiredSandboxes,
|
||||
logger: FakeLogger,
|
||||
docker_client: FakeDockerClient,
|
||||
sandbox_reconciler: SandboxSessionReconciler | None = None,
|
||||
) -> AppContainer:
|
||||
observability = ObservabilityRuntime(
|
||||
logger=logger,
|
||||
|
|
@ -134,6 +192,13 @@ def build_container(
|
|||
tracer=NoopTracer(),
|
||||
)
|
||||
repositories = AppRepositories(sandbox_session=InMemorySandboxSessionRepository())
|
||||
reconciler = sandbox_reconciler
|
||||
if reconciler is None:
|
||||
reconciler = SandboxSessionReconciler(
|
||||
state_source=EmptySandboxState(),
|
||||
registry=repositories.sandbox_session,
|
||||
logger=logger,
|
||||
)
|
||||
usecases = AppUsecases(
|
||||
create_sandbox=create_sandbox_usecase,
|
||||
cleanup_expired_sandboxes=cleanup_usecase,
|
||||
|
|
@ -143,6 +208,7 @@ def build_container(
|
|||
observability=observability,
|
||||
repositories=repositories,
|
||||
usecases=usecases,
|
||||
sandbox_reconciler=reconciler,
|
||||
_docker_client=docker_client,
|
||||
)
|
||||
|
||||
|
|
@ -401,6 +467,85 @@ def test_post_create_maps_generic_sandbox_errors_to_internal_error(monkeypatch)
|
|||
assert docker_client.close_calls == 1
|
||||
|
||||
|
||||
def test_startup_reconciliation_reuses_existing_container_after_restart(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
config = build_config()
|
||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
||||
restored_session = SandboxSession(
|
||||
session_id=SESSION_ID,
|
||||
chat_id=CHAT_ID,
|
||||
container_id='container-123',
|
||||
status=SandboxStatus.RUNNING,
|
||||
created_at=created_at,
|
||||
expires_at=created_at + timedelta(minutes=5),
|
||||
)
|
||||
logger = FakeLogger()
|
||||
docker_client = FakeDockerClient()
|
||||
runtime = FakeLifecycleRuntime([restored_session])
|
||||
repository = InMemorySandboxSessionRepository()
|
||||
observability = ObservabilityRuntime(
|
||||
logger=logger,
|
||||
metrics=NoopMetrics(),
|
||||
tracer=NoopTracer(),
|
||||
)
|
||||
repositories = AppRepositories(sandbox_session=repository)
|
||||
reconciler = SandboxSessionReconciler(
|
||||
state_source=runtime,
|
||||
registry=repository,
|
||||
logger=logger,
|
||||
)
|
||||
usecases = AppUsecases(
|
||||
create_sandbox=CreateSandbox(
|
||||
repository=repository,
|
||||
locker=ProcessLocalSandboxLifecycleLocker(),
|
||||
runtime=runtime,
|
||||
clock=FakeClock(created_at),
|
||||
logger=logger,
|
||||
ttl=timedelta(minutes=5),
|
||||
),
|
||||
cleanup_expired_sandboxes=CleanupExpiredSandboxes(
|
||||
repository=repository,
|
||||
locker=ProcessLocalSandboxLifecycleLocker(),
|
||||
runtime=runtime,
|
||||
clock=FakeClock(created_at),
|
||||
logger=logger,
|
||||
),
|
||||
)
|
||||
container = AppContainer(
|
||||
config=config,
|
||||
observability=observability,
|
||||
repositories=repositories,
|
||||
usecases=usecases,
|
||||
sandbox_reconciler=reconciler,
|
||||
_docker_client=docker_client,
|
||||
)
|
||||
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
|
||||
monkeypatch.setattr(
|
||||
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
|
||||
)
|
||||
|
||||
app = app_module.create_app(config=config)
|
||||
|
||||
status_code, response = asyncio.run(
|
||||
exercise_create_request(app, {'chat_id': str(CHAT_ID)})
|
||||
)
|
||||
|
||||
assert status_code == 200
|
||||
assert response == {
|
||||
'session_id': str(SESSION_ID),
|
||||
'chat_id': str(CHAT_ID),
|
||||
'container_id': 'container-123',
|
||||
'status': 'running',
|
||||
'expires_at': '2026-04-02T12:05:00Z',
|
||||
}
|
||||
assert runtime.list_calls == 1
|
||||
assert runtime.create_calls == []
|
||||
assert runtime.stop_calls == []
|
||||
assert repository.get_active_by_chat_id(CHAT_ID) == restored_session
|
||||
assert docker_client.close_calls == 1
|
||||
|
||||
|
||||
def test_removed_user_endpoint_returns_not_found(monkeypatch) -> None:
|
||||
config = build_config()
|
||||
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue