# BAT-049 Design - Status: confirmed - Batch: BAT-049 ## Architecture Summary 알람 시스템을 application 레이어의 `AlarmEmitter`(포트 경유) + adapters 레이어의 `publish_alarm_event()` 확장으로 구현한다. 기존 레이어드 아키텍처(domain/application/adapters/runtime)와 의존성 역전 규칙을 그대로 따른다. ``` 발생 지점(execution.py 등) │ alarm_emitter.publish_alarm(code, title, fields) ▼ AlarmEmitter (application 레이어) │ MessagingTransport.publish_alarm_event(routing_key, payload) ▼ RabbitMQTransport.publish_alarm_event() (adapters 레이어) │ basic_publish(exchange=trex.alerts, routing_key=alarm.critical.trex-core, mandatory=False) ▼ trex.alerts exchange → (notifier가 alarm.# 패턴으로 바인딩) ``` `AlarmEmitter`는 `MessagingTransport` 포트만 주입받는다. pika 직접 import 없음. domain 레이어 접촉 없음. ## Changed Areas | 파일 | 변경 유형 | 내용 | |---|---|---| | `docs/interface-contract.md` | 추가(기존 불변) | `Interface: trex.alerts (알람 채널, dcy-033)` 섹션 신규 | | `trex_core/adapters/messaging/topology.py` | 추가 | `ALERTS_EXCHANGE = "trex.alerts"` 상수 추가 | | `trex_core/adapters/messaging/rabbitmq.py` | 확장 | `declare_topology()`에 trex.alerts exchange 선언 + `publish_alarm_event()` 메서드 신규 | | `trex_core/adapters/messaging/in_memory.py` | 확장 | `publish_alarm_event()` stub 추가 (발행 기록 + unavailable 시 TransportUnavailableError) | | `trex_core/application/messaging.py` | 확장 | `MessagingTransport` Protocol에 `publish_alarm_event()` 추가 | | `trex_core/application/alarm_emitter.py` | 신규 | `AlarmEmitter` 클래스 구현 | | `trex_core/application/execution.py` | 배선 | `fill_price_unresolvable`, `sell_skipped_no_balance` 알람 호출 추가 | | `trex_core/application/reconciliation.py` | 배선 | `core_holding_broker_flat` 알람 호출 추가 | | `trex_core/application/ledger_outbox.py` | 배선 | `ledger_delivery_failed` 알람 호출 추가 | | `trex_core/application/killswitch.py` | 배선 | `kill_switch_fired` 알람 호출 추가 | | `trex_core/adapters/broker/rabbitmq.py`(`messaging/rabbitmq.py`) | 배선 | `publish_unavailable_after_reconnect` / `dlq_publish_failed` — 콜백 패턴 | | `trex_core/application/portfolio.py` / `snapshot_scheduler.py` | 배선 | `portfolio_hub_unavailable` 알람 호출 추가 | | `trex_core/runtime/reset.py` | 배선 | `schema_verification_failed` / `migration_failed` 알람 호출 (transport None 안전) | | `trex_core/runtime/daemon.py` | 조립 | `AlarmEmitter` 생성 + 각 서비스에 주입 | | `tests/application/test_alarm_emitter.py` | 신규 | AlarmEmitter 단위 테스트 | | `tests/adapters/messaging/test_topology.py` | 확장 | declare_topology trex.alerts assertion | | `docs/runtime-flows.md` | 추가(기존 불변) | 알람 발신 흐름 섹션 신규 | ## Key Decisions ### KD-1: publish_unavailable_after_reconnect / dlq_publish_failed — 콜백 패턴 채택 **문제**: 두 alarm code는 `trex_core/adapters/messaging/rabbitmq.py`(어댑터) 내부에서 발생하지만, `AlarmEmitter`는 application 레이어에 있다. 어댑터가 application을 직접 import하면 의존성이 역전된다. **채택 결정: 콜백(callable) 주입** - `RabbitMQTransport`에 `alarm_callback: Optional[Callable[[str, str, dict], None]]` 파라미터를 추가한다. 시그니처: `(code: str, title: str, fields: dict) -> None`. - `daemon.py`에서 `AlarmEmitter.publish_alarm`을 콜백으로 주입한다. - 어댑터는 application 타입을 import하지 않고, callable 타입 힌트(`Optional[Callable]`)만 사용. - 콜백 미주입 시(기본값 None): 기존 동작 유지 (로그만). - 콜백이 raise하면 어댑터는 except로 삼킨다(best-effort 원칙 일관). **기각 대안**: - 이벤트 버스/큐: 과도한 복잡성, 결정론적 단위 테스트 어려움. - AlarmEmitter를 adapters 레이어로 이동: domain/application 순수성 원칙 위반. - Transport 프로토콜에 alarm_callback getter 추가: Protocol 오염 + 테스트 어려움. ### KD-2: reset.py transport None 안전 — AlarmEmitter None-guard 패턴 **문제**: `reset.py`는 CLI 진입점으로 `MessagingTransport`가 없다. AlarmEmitter에 None transport를 전달하면 호출 시 crash. **채택 결정**: `AlarmEmitter.__init__`이 `Optional[MessagingTransport]`를 받고, `publish_alarm()` 내부에서 transport가 None이면 WARN 로그 후 skip. - `reset.py`는 `AlarmEmitter(transport=None)`로 생성하거나 `publish_alarm()` 호출 전 None 체크. - 실제로는 `run_reset()` 함수가 `AlarmEmitter`를 직접 받는 시그니처로 확장하거나, reset 전용 경량 emitter를 분리한다. - 가장 단순한 방법: `AlarmEmitter(transport=None)` 허용 + None guard → reset 코드에서 생성 부담 최소화. ### KD-3: publish_alarm_event 시그니처 — transport 레이어 알람 전용 메서드 **결정**: `MessagingTransport` 프로토콜에 `publish_alarm_event(routing_key: str, payload: dict) -> None` 추가. - `publish_event()`와 구분하는 이유: alarm은 `trex.alerts` exchange, `mandatory=False`, `delivery_mode=1`(best-effort, 비영속) vs events는 `trex.events`, `mandatory=True`, `delivery_mode=2`. - `InMemoryTransport`: 발행된 alarm을 `_alarm_events: list[tuple[str, dict]]`에 기록 + unavailable 시 TransportUnavailableError 발생(호출자가 삼킴). - `RabbitMQTransport`: `publish_alarm_event()`는 `basic_publish(exchange=trex.alerts, mandatory=False, delivery_mode=1)`. 연결 실패 시 TransportUnavailableError raise(AlarmEmitter가 삼킴). retry-on-failure 없음(best-effort 원칙). ### KD-4: AlarmEmitter 인터페이스 ```python class AlarmEmitter: def __init__(self, transport: Optional[MessagingTransport]) -> None: ... def publish_alarm( self, code: str, title: str, *, fields: dict | None = None, severity: str = "critical", ) -> None: """Best-effort 발행. 실패 시 ERROR 로그 후 예외 삼킴.""" ... ``` - `source="trex-core"`, `schema_version="1"` 상수화. - `occurred_at` = 발행 시점 KST ISO-8601. - routing key = `alarm.{severity}.trex-core`. - 발행 실패(TransportUnavailableError, 기타 모든 예외): ERROR 로그 후 예외 삼킴(re-raise 금지). ## Edge Cases - **transport disconnect 중 알람 발행**: `publish_alarm_event()` → TransportUnavailableError → AlarmEmitter가 ERROR 로그 후 삼킴. 본업 경로 비차단. - **rabbitmq.py 콜백이 None인 경우**: `alarm_callback` guard로 skip. 기존 코드 변경 없이 하위호환. - **reset.py에서 AlarmEmitter(transport=None)**: None guard 분기 → INFO 로그("alarm skipped: no transport") 후 skip. - **mandatory=False + notifier 미연결**: silent drop. UnroutableError 없음. - **8개 code 중 일부 경로에서 AlarmEmitter 미주입**: daemon.py에서 전부 주입. 미주입 시 AttributeError → execution_loop except로 삼킴(기존 보호 패턴). - **AlarmEmitter.publish_alarm 내 예외 루프**: 각 호출마다 독립 try/except. 한 코드 발행 실패가 동일 tick의 다른 호출에 영향 없음. ## Architecture Impact `both` — runtime-flows.md + interface-contract.md 양쪽 업데이트 필요. ## Reference Doc Update Plan - `docs/interface-contract.md`: `Interface: trex.alerts (알람 채널, dcy-033)` 섹션 추가 + Change Log 갱신 + Last Updated 갱신. - `docs/runtime-flows.md`: `Flow: 알람 발신 (trex.alerts, dcy-033)` 섹션 추가. 기존 섹션 불변.