AlarmEmitter(application, best-effort 비차단)+trex.alerts topic exchange 가산·격리+8 CRITICAL code 배선. 608 passed, opus QC PASS, 팀장 e2e(텔레그램 HTTP200) 통과. REQ-098~101 Implemented. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
7.5 KiB
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.alertsexchange,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 인터페이스
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_callbackguard로 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)섹션 추가. 기존 섹션 불변.