"""TenantEngineRegistry & EngineFactory (계획서 B: 싱글톤 제거, 테넌트별 엔진 지연생성+캐시). Chat_server 는 `chat_engine = ChatEngine()` 전역 무인자 싱글톤이라 멀티테넌트가 불가능했다. 여기서는 테넌트별로 config 를 주입해 엔진을 조립하고(lock 보호 지연생성), 캐시한다. 카드/모델 갱신 시 reload(tenant_id)로 해당 테넌트만 재조립한다. 엔진(TenantEngine)은 요청 간 공유되지만 **가변 episode 상태를 갖지 않는다**(EpisodeState 외부화). """ import asyncio from collections import defaultdict from typing import Dict, Optional from common.logger import LOG from negotiation.cards.action_card_mapper import ActionCardMapper from tenancy.config import TenantConfig from tenancy.config_loader import TenantConfigLoader class TenantEngine: """한 테넌트의 협상 엔진 조립체 (불변 협력자 보관). P4 범위: config + ActionCardMapper. policy/orchestrator/chat_engine 은 후속 단계에서 이 팩토리 조립 라인에 추가된다(P5 ModelRepository warm-start, P7 ChatEngine). """ def __init__(self, config: TenantConfig, mapper: ActionCardMapper): self.tenant_id = config.tenant_id self.company_id = config.company_id or config.tenant_id # _base/미시드 시 tenant_id 사용 self.config = config self.mapper = mapper @property def action_space_size(self) -> int: return self.mapper.action_space_size @property def state_space_size(self) -> int: return self.config.state.state_space_size class EngineFactory: """TenantConfig → TenantEngine 조립. (PolicyFactory/ModelRepository 는 후속 단계 결합)""" @staticmethod def build(config: TenantConfig) -> TenantEngine: mapper = ActionCardMapper(config.action_mapping) return TenantEngine(config, mapper) class TenantEngineRegistry: def __init__(self, loader: Optional[TenantConfigLoader] = None, factory: type[EngineFactory] = EngineFactory): self._loader = loader or TenantConfigLoader() self._factory = factory self._engines: Dict[str, TenantEngine] = {} self._locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) def is_registered(self, tenant_id: str) -> bool: return self._loader.is_registered(tenant_id) async def get_engine(self, tenant_id: str) -> TenantEngine: cached = self._engines.get(tenant_id) if cached is not None: return cached # 테넌트별 lock 으로 동시 첫 요청에서 1회만 조립 (double-checked). async with self._locks[tenant_id]: cached = self._engines.get(tenant_id) if cached is not None: return cached if not self.is_registered(tenant_id): raise KeyError(f"unregistered tenant: {tenant_id}") config = self._loader.load(tenant_id) engine = self._factory.build(config) self._engines[tenant_id] = engine LOG.i(f"[TenantEngineRegistry] built engine tenant_id={tenant_id} " f"state={engine.state_space_size} action={engine.action_space_size}") return engine async def reload(self, tenant_id: str) -> Optional[TenantEngine]: """해당 테넌트만 재조립 (카드/모델 갱신 시). 미등록이면 None.""" async with self._locks[tenant_id]: self._loader.invalidate(tenant_id) self._engines.pop(tenant_id, None) if not self.is_registered(tenant_id): return None config = self._loader.load(tenant_id) engine = self._factory.build(config) self._engines[tenant_id] = engine return engine def cached_tenants(self) -> list[str]: return list(self._engines.keys()) # 앱 전역 레지스트리 (backend 의 모듈 싱글톤 컨벤션). 미들웨어/deps 가 참조한다. tenant_registry = TenantEngineRegistry()