"""Messaging transport port + DTOs (REQ-021, REQ-022, dcy-006). Application-layer contract for trex-core ↔ 전략 모듈 통신. Domain stays free of AMQP / pika dependencies (NFR-1); concrete adapters live in trex_core.adapters.messaging. Three patterns (dcy-006 OQ-2): 1. snapshot RPC (request-reply) — read-only, transient. 2. intent admission RPC (request-reply) — durable, correlation_id dedup. 3. event pub/sub — topic exchange, per-strategy queue, last-wins. Request payloads carry view/strategy_id in the message body; events carry strategy_id/symbol in the routing key (REQ-021 mapping principle). """ from __future__ import annotations import threading from dataclasses import dataclass, field from enum import Enum from typing import Any, Callable, Optional, Protocol class TransportHealth(str, Enum): """Connection state exposed to application for degrade branches (REQ-024).""" CONNECTED = "connected" DISCONNECTED = "disconnected" RECONNECTING = "reconnecting" @dataclass(frozen=True) class RpcRequest: """Inbound RPC message handed to a consumer handler.""" payload: dict correlation_id: str reply_to: Optional[str] = None properties: dict[str, Any] = field(default_factory=dict) @dataclass(frozen=True) class RpcResponse: """Outbound RPC response returned from a consumer handler.""" payload: dict class TransportError(Exception): """Base class for messaging transport failures.""" class TransportUnavailableError(TransportError): """Raised when transport.health() is not CONNECTED and the caller cannot proceed (REQ-024: no local buffering, conservative halt).""" class RpcTimeoutError(TransportError): """RPC reply not received within the configured timeout.""" # Type aliases for handlers RpcHandler = Callable[[RpcRequest], RpcResponse] EventHandler = Callable[[str, dict], None] # (routing_key, payload) class MessagingTransport(Protocol): """Transport boundary for messaging-based communication (REQ-022).""" def declare_topology(self) -> None: """Declare exchanges, queues, bindings (REQ-021).""" ... def publish_event( self, routing_key: str, payload: dict, *, mandatory: bool = True, ) -> None: """Publish to the events topic exchange. Uses publisher confirms; unroutable or NACKed messages are routed to the DLQ (REQ-023). Raises TransportUnavailableError if not connected.""" ... def publish_alarm_event( self, routing_key: str, payload: dict, ) -> None: """Publish to the trex.alerts exchange (dcy-033 / REQ-099). Best-effort: mandatory=False so an unroutable message (notifier not connected) is silently dropped — no DLQ, no retry. Raises TransportUnavailableError if not connected (caller must swallow). """ ... def call_rpc( self, queue: str, payload: dict, *, correlation_id: str, timeout: float, ) -> dict: """Client-side RPC: publish to `queue`, wait for reply. Raises RpcTimeoutError on timeout, TransportUnavailableError on outage.""" ... def consume_rpc_forever( self, queue: str, handler: RpcHandler, stop: threading.Event, ) -> None: """Server-side RPC consumption. Blocks until `stop` is set. Auto-reconnects on connection drop (REQ-022 AC-4).""" ... def consume_events_forever( self, queue: str, binding_pattern: str, handler: EventHandler, stop: threading.Event, ) -> None: """Subscribe to events matching `binding_pattern` on the events exchange, dispatch to handler. Blocks until `stop` is set.""" ... def keepalive_tick(self) -> None: """Process pending I/O frames to keep the connection alive (REQ-092). MUST be called from the thread that owns this transport instance. Non-blocking (time_limit=0 equivalent). Implementations that do not need keepalive (e.g. InMemoryTransport) should be a no-op. """ ... def health(self) -> TransportHealth: """Current connection state (REQ-024).""" ... def close(self) -> None: """Release connection resources.""" ...