feat(alarm): trex-core 알람 emitter + trex.alerts exchange (dcy-033 BAT-049, Implemented)

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
This commit is contained in:
2026-06-21 19:25:55 +09:00
co-authored by Claude Opus 4.8
parent b2165eac25
commit d4c5fcbba1
29 changed files with 1195 additions and 47 deletions
@@ -1,4 +1,4 @@
"""InMemoryTransport stub tests (REQ-022 AC-1 + REQ-024 degrade)."""
"""InMemoryTransport stub tests (REQ-022 AC-1 + REQ-024 degrade + REQ-099 alarm)."""
from __future__ import annotations
import threading
@@ -86,3 +86,23 @@ def test_publish_event_with_no_subscriber_routes_to_dlq():
dlq = t.dlq_messages()
assert len(dlq) == 1
assert dlq[0][0] == "slot_filled_buy.A.005930"
# dcy-033 / REQ-099: alarm channel tests
def test_publish_alarm_event_records_event():
"""Alarm event is recorded in _alarm_events when transport is available."""
t = InMemoryTransport()
t.publish_alarm_event("alarm.critical.trex-core", {"code": "kill_switch_fired"})
alarms = t.alarm_events()
assert len(alarms) == 1
rk, payload = alarms[0]
assert rk == "alarm.critical.trex-core"
assert payload["code"] == "kill_switch_fired"
def test_publish_alarm_event_raises_when_unavailable():
"""Alarm publish raises TransportUnavailableError when transport is down."""
t = InMemoryTransport()
t.set_available(False)
with pytest.raises(TransportUnavailableError):
t.publish_alarm_event("alarm.critical.trex-core", {"code": "test"})
+12 -1
View File
@@ -1,4 +1,4 @@
"""Topology constants + routing key builders (REQ-021)."""
"""Topology constants + routing key builders (REQ-021, REQ-099)."""
from __future__ import annotations
from trex_core.adapters.messaging import topology
@@ -12,6 +12,17 @@ def test_topology_constants():
assert not hasattr(topology, "SNAPSHOT_REQ_QUEUE")
# dcy-033 / REQ-099: trex.alerts exchange constant + routing key builder
def test_alerts_exchange_constant():
assert topology.ALERTS_EXCHANGE == "trex.alerts"
def test_alarm_routing_key_format():
assert topology.alarm_routing_key("critical", "trex-core") == "alarm.critical.trex-core"
assert topology.alarm_routing_key("warn", "trex-orb-v1") == "alarm.warn.trex-orb-v1"
assert topology.alarm_routing_key("info", "kis-data-hub") == "alarm.info.kis-data-hub"
def test_event_routing_key_format():
assert topology.event_routing_key("slot_filled_buy", "A", "005930") == \
"slot_filled_buy.A.005930"
+156
View File
@@ -0,0 +1,156 @@
"""AlarmEmitter unit tests (dcy-033 / REQ-100).
Coverage:
- 8 CRITICAL code × (a) normal publish + envelope validation
× (b) TransportUnavailableError → no re-raise + ERROR log
- arbitrary exception swallowing
- transport=None skip (reset.py / CLI path)
- routing key format: alarm.critical.trex-core
"""
from __future__ import annotations
import logging
import pytest
from trex_core.adapters.messaging.in_memory import InMemoryTransport
from trex_core.application.alarm_emitter import AlarmEmitter
# ------------------------------------------------------------------ helpers
CRITICAL_CODES = [
"fill_price_unresolvable",
"sell_skipped_no_balance",
"core_holding_broker_flat",
"ledger_delivery_failed",
"kill_switch_fired",
"publish_unavailable_after_reconnect",
"dlq_publish_failed",
"portfolio_hub_unavailable",
]
_ROUTING_KEY = "alarm.critical.trex-core"
def _make_emitter() -> tuple[AlarmEmitter, InMemoryTransport]:
transport = InMemoryTransport()
return AlarmEmitter(transport), transport
# ------------------------------------------------------------------ envelope validation
@pytest.mark.parametrize("code", CRITICAL_CODES)
def test_publish_alarm_envelope_fields(code: str):
"""(a) Normal publish: envelope contains all 7 required fields with correct values."""
emitter, transport = _make_emitter()
fields_in = {"slot_id": "abc", "symbol": "005930"}
emitter.publish_alarm(code, f"Test alarm for {code}", fields=fields_in)
alarms = transport.alarm_events()
assert len(alarms) == 1, f"Expected 1 alarm event for code={code}"
rk, payload = alarms[0]
assert rk == _ROUTING_KEY, f"routing key mismatch: {rk}"
# REQ-098: all 7 envelope fields
assert payload["source"] == "trex-core"
assert payload["severity"] == "critical"
assert payload["code"] == code
assert payload["title"] == f"Test alarm for {code}"
assert payload["fields"] == fields_in
assert "occurred_at" in payload
assert payload["schema_version"] == "1"
@pytest.mark.parametrize("code", CRITICAL_CODES)
def test_publish_alarm_transport_unavailable_no_reraise(
code: str, caplog: pytest.LogCaptureFixture
):
"""(b) TransportUnavailableError must not propagate; ERROR log must appear."""
emitter, transport = _make_emitter()
transport.set_available(False)
with caplog.at_level(logging.ERROR, logger="trex_core.application.alarm_emitter"):
# Must NOT raise
emitter.publish_alarm(code, f"Test alarm for {code}")
# No alarms recorded (transport was down)
assert transport.alarm_events() == []
# ERROR log must have been emitted
assert any("alarm publish" in r.message for r in caplog.records), (
f"Expected ERROR log for code={code}"
)
# ------------------------------------------------------------------ additional edge cases
@pytest.mark.parametrize("code", CRITICAL_CODES)
def test_publish_alarm_arbitrary_exception_swallowed(code: str, caplog: pytest.LogCaptureFixture):
"""Any exception (not just TransportUnavailableError) is swallowed + ERROR logged."""
class BrokenTransport:
def publish_alarm_event(self, routing_key, payload):
raise RuntimeError("boom")
def health(self):
from trex_core.application.messaging import TransportHealth
return TransportHealth.CONNECTED
emitter = AlarmEmitter(BrokenTransport()) # type: ignore[arg-type]
with caplog.at_level(logging.ERROR, logger="trex_core.application.alarm_emitter"):
emitter.publish_alarm(code, "boom test") # Must NOT raise
assert any("alarm publish" in r.message for r in caplog.records)
def test_publish_alarm_none_transport_skip(caplog: pytest.LogCaptureFixture):
"""transport=None: publish is skipped with INFO log (reset.py / CLI path, REQ-100 KD-2)."""
emitter = AlarmEmitter(None)
with caplog.at_level(logging.INFO, logger="trex_core.application.alarm_emitter"):
emitter.publish_alarm("kill_switch_fired", "no transport test")
assert any("alarm skipped" in r.message for r in caplog.records)
def test_publish_alarm_empty_fields_default():
"""fields defaults to {} when not provided — not None in payload."""
emitter, transport = _make_emitter()
emitter.publish_alarm("kill_switch_fired", "no fields")
_, payload = transport.alarm_events()[0]
assert payload["fields"] == {}
def test_publish_alarm_routing_key_format():
"""routing key must be alarm.critical.trex-core for default severity."""
emitter, transport = _make_emitter()
emitter.publish_alarm("kill_switch_fired", "routing key test")
rk, _ = transport.alarm_events()[0]
assert rk == "alarm.critical.trex-core"
# ------------------------------------------------------------------ domain purity sweep
def test_alarm_emitter_no_pika_import():
"""AlarmEmitter module must not import pika (domain/application purity)."""
import importlib
import sys
# Ensure pika is not in alarm_emitter's import chain at module level.
mod = sys.modules.get("trex_core.application.alarm_emitter")
if mod is None:
mod = importlib.import_module("trex_core.application.alarm_emitter")
# pika should NOT be in the module's global namespace
assert "pika" not in dir(mod), "alarm_emitter must not import pika directly"
def test_alarm_emitter_no_telegram_reference():
"""Verify no TELEGRAM_* references in alarm_emitter source (REQ-100 AC-5)."""
import inspect
import trex_core.application.alarm_emitter as ae_mod
source = inspect.getsource(ae_mod)
assert "TELEGRAM" not in source, "alarm_emitter must not reference TELEGRAM_*"
assert "telegram" not in source.lower(), "alarm_emitter must not reference telegram"