[fix] restart gap
This commit is contained in:
parent
770af1fe76
commit
50af62b3fb
10 changed files with 348 additions and 4 deletions
|
|
@ -11,6 +11,7 @@ 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 adapter.sandbox.reconciliation import SandboxSessionReconciler
|
||||
from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
|
||||
from repository.sandbox_session import InMemorySandboxSessionRepository
|
||||
from usecase.interface import Clock
|
||||
|
|
@ -34,6 +35,7 @@ class AppContainer:
|
|||
observability: ObservabilityRuntime
|
||||
repositories: AppRepositories
|
||||
usecases: AppUsecases
|
||||
sandbox_reconciler: SandboxSessionReconciler = field(repr=False)
|
||||
_docker_client: DockerClient = field(repr=False)
|
||||
_is_shutdown: bool = field(default=False, init=False, repr=False)
|
||||
|
||||
|
|
@ -80,6 +82,11 @@ def build_container(
|
|||
sandbox_repository = InMemorySandboxSessionRepository()
|
||||
sandbox_locker = ProcessLocalSandboxLifecycleLocker()
|
||||
sandbox_runtime = DockerSandboxRuntime(app_config.sandbox, docker_client)
|
||||
sandbox_reconciler = SandboxSessionReconciler(
|
||||
state_source=sandbox_runtime,
|
||||
registry=sandbox_repository,
|
||||
logger=observability.logger,
|
||||
)
|
||||
|
||||
repositories = AppRepositories(sandbox_session=sandbox_repository)
|
||||
usecases = AppUsecases(
|
||||
|
|
@ -105,5 +112,6 @@ def build_container(
|
|||
observability=observability,
|
||||
repositories=repositories,
|
||||
usecases=usecases,
|
||||
sandbox_reconciler=sandbox_reconciler,
|
||||
_docker_client=docker_client,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from domain.error import SandboxError, SandboxStartError
|
|||
from domain.sandbox import SandboxSession, SandboxStatus
|
||||
from usecase.interface import SandboxRuntime
|
||||
|
||||
SANDBOX_LABELS = ('session_id', 'chat_id', 'expires_at')
|
||||
|
||||
|
||||
class DockerSandboxRuntime(SandboxRuntime):
|
||||
def __init__(
|
||||
|
|
@ -69,6 +71,23 @@ class DockerSandboxRuntime(SandboxRuntime):
|
|||
except DockerException as exc:
|
||||
raise SandboxError('sandbox_stop_failed') from exc
|
||||
|
||||
def list_active_sessions(self) -> list[SandboxSession]:
|
||||
try:
|
||||
containers = self._client.containers.list(
|
||||
filters={'label': list(SANDBOX_LABELS)}
|
||||
)
|
||||
except DockerException as exc:
|
||||
raise SandboxError('sandbox_list_failed') from exc
|
||||
|
||||
sessions: list[SandboxSession] = []
|
||||
for container in containers:
|
||||
session = self._session_from_container(container)
|
||||
if session is None:
|
||||
continue
|
||||
sessions.append(session)
|
||||
|
||||
return sessions
|
||||
|
||||
def _labels(
|
||||
self,
|
||||
session_id: UUID,
|
||||
|
|
@ -120,5 +139,50 @@ class DockerSandboxRuntime(SandboxRuntime):
|
|||
raise ValueError('invalid host path')
|
||||
return host_path
|
||||
|
||||
def _session_from_container(self, container: object) -> SandboxSession | None:
|
||||
container_id = str(getattr(container, 'id', '')).strip()
|
||||
labels = getattr(container, 'labels', None)
|
||||
if not container_id or not isinstance(labels, dict):
|
||||
return None
|
||||
|
||||
try:
|
||||
session_id = UUID(labels['session_id'])
|
||||
chat_id = UUID(labels['chat_id'])
|
||||
created_at = self._container_created_at(container)
|
||||
expires_at = _parse_datetime(labels['expires_at'])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return SandboxSession(
|
||||
session_id=session_id,
|
||||
chat_id=chat_id,
|
||||
container_id=container_id,
|
||||
status=SandboxStatus.RUNNING,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def _container_created_at(self, container: object) -> datetime:
|
||||
attrs = getattr(container, 'attrs', None)
|
||||
if not isinstance(attrs, dict):
|
||||
reload_container = getattr(container, 'reload', None)
|
||||
if callable(reload_container):
|
||||
reload_container()
|
||||
attrs = getattr(container, 'attrs', None)
|
||||
|
||||
if not isinstance(attrs, dict):
|
||||
raise ValueError('invalid container attrs')
|
||||
|
||||
raw_created_at = attrs.get('Created')
|
||||
if not isinstance(raw_created_at, str):
|
||||
raise ValueError('invalid created_at')
|
||||
|
||||
return _parse_datetime(raw_created_at)
|
||||
|
||||
def _host_path(self, path_value: str) -> Path:
|
||||
return Path(path_value).expanduser().resolve(strict=False)
|
||||
|
||||
|
||||
def _parse_datetime(value: str) -> datetime:
|
||||
normalized = f'{value[:-1]}+00:00' if value.endswith('Z') else value
|
||||
return datetime.fromisoformat(normalized)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ def _build_startup_handler(
|
|||
if task is not None and not task.done():
|
||||
return
|
||||
|
||||
await asyncio.to_thread(container.sandbox_reconciler.execute)
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
setattr(app.state, APP_CLEANUP_STOP_STATE, stop_event)
|
||||
setattr(
|
||||
|
|
|
|||
0
adapter/sandbox/__init__.py
Normal file
0
adapter/sandbox/__init__.py
Normal file
39
adapter/sandbox/reconciliation.py
Normal file
39
adapter/sandbox/reconciliation.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from domain.sandbox import SandboxSession
|
||||
from usecase.interface import Logger
|
||||
|
||||
|
||||
class SandboxSessionStateSource(Protocol):
|
||||
def list_active_sessions(self) -> list[SandboxSession]: ...
|
||||
|
||||
|
||||
class SandboxSessionRegistry(Protocol):
|
||||
def replace_all(self, sessions: list[SandboxSession]) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SandboxSessionReconciler:
|
||||
state_source: SandboxSessionStateSource
|
||||
registry: SandboxSessionRegistry
|
||||
logger: Logger
|
||||
|
||||
def execute(self) -> list[SandboxSession]:
|
||||
sessions_by_chat_id: dict[UUID, SandboxSession] = {}
|
||||
for session in sorted(
|
||||
self.state_source.list_active_sessions(),
|
||||
key=lambda item: item.created_at,
|
||||
):
|
||||
sessions_by_chat_id[session.chat_id] = session
|
||||
|
||||
sessions = list(sessions_by_chat_id.values())
|
||||
self.registry.replace_all(sessions)
|
||||
self.logger.info(
|
||||
'sandbox_reconciled',
|
||||
attrs={
|
||||
'session_count': len(sessions),
|
||||
},
|
||||
)
|
||||
return sessions
|
||||
Loading…
Add table
Add a link
Reference in a new issue