o2o-negosium-original/agent/tenancy/registry.py

97 lines
3.9 KiB
Python

"""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()