feat(messaging): OPS-005 이벤트 발행 연결 신뢰성 (BAT-047)
발행 전용 연결의 idle heartbeat 사망 후 첫 발행 유실 결함 제거. - REQ-091 발행 재시도 + 강제 재연결(_force_reconnect, is_open 불신) - REQ-092 idle keepalive(execution-loop 단일스레드 틱, process_data_events time_limit=0) - REQ-093 DLQ 가시성 + emit 실패 ERROR 경보(구조화 로그, dcy-029 재사용) - REQ-094 baseline 문서 정합(runtime-flows/operations/project-structure) 독립 opus QC PASS-WITH-NOTES → 시정 2건(ruff F401, AC-3 프로덕션 경로) 완료. 554 passed/47 skipped/회귀 0/ruff clean. NFR-1 wire 무회귀(interface-contract diff 0). BAT-047 release-candidate, REQ-091~094 Implemented. dcy-031. M2 트랙B. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# BAT-047 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-047
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
발행 신뢰성 보강은 `adapters/messaging/rabbitmq.py`의 발행 경로(`publish_event`, `_publish_dlq`)와 `runtime/daemon.py`의 `_make_event_emitter`에 국소화한다. wire 계약(AMQP 토폴로지/exchange/routing key/payload 스키마)·consume/RPC 경로(`consume_rpc_forever`, `consume_events_forever`, `call_rpc`)·`declare_topology`·슬롯 도메인·broker 집행 로직은 불변이다.
|
||||
|
||||
변경 계층:
|
||||
- `adapters/messaging/rabbitmq.py` (transport 어댑터 레이어)
|
||||
- `publish_event`: 발행 실패 시 강제 재연결(`close` + `_connect`) 후 1회 재시도. 타임아웃 `socket_timeout`으로 상한 제어. `is_open` 맹신 제거.
|
||||
- `keepalive_tick()`: `process_data_events(time_limit=0)` 호출 — 발행 전용 연결 소유 스레드에서만 호출.
|
||||
- `_publish_dlq`: best-effort 실패 시 WARNING → ERROR 격상.
|
||||
- `runtime/daemon.py` (runtime 레이어)
|
||||
- `_make_event_emitter`: `TransportUnavailableError` 처리 WARNING → ERROR 격상, routing key + correlation 정보 포함.
|
||||
- `_execution_loop`: 각 틱 직전 `event_publisher.keepalive_tick()` 호출(동일 스레드 보장).
|
||||
|
||||
## Changed Areas
|
||||
|
||||
- Modules:
|
||||
- `trex_core/adapters/messaging/rabbitmq.py` — `publish_event`, `keepalive_tick`(신규), `_publish_dlq`
|
||||
- `trex_core/runtime/daemon.py` — `_make_event_emitter`, `_execution_loop`
|
||||
- Tests:
|
||||
- `tests/adapters/messaging/test_publish_reliability.py` (신규) — AC 커버
|
||||
- `tests/application/test_messaging_event_publisher.py` — 기존 회귀 유지
|
||||
- Docs:
|
||||
- `docs/runtime-flows.md` — 발행 재시도/keepalive 흐름, DLQ 관측 경로 추가(REQ-094)
|
||||
- `docs/project-structure.md` — `keepalive_tick` 컴포넌트 기술 추가(REQ-094)
|
||||
- `docs/operations.md` — DLQ 가시성/경보 운영 절차 신규(REQ-094)
|
||||
- Interfaces: AMQP wire 계약 변경 없음. `interface-contract.md` 불변.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
### OQ-1: keepalive 실행 위치 — Answered
|
||||
|
||||
**결정: execution-loop 틱에 `event_publisher.keepalive_tick()` 삽입(동일 스레드 모델).**
|
||||
|
||||
근거:
|
||||
- pika `BlockingConnection`은 생성 스레드에서만 안전하게 I/O 호출 가능(공식 문서: "pika is not threadsafe").
|
||||
- `event_publisher`는 daemon 초기화 시 생성되어 `_execution_loop` 스레드(`trex-execution`)가 독점 소유.
|
||||
- 별도 keepalive 스레드를 띄우면 그 스레드가 발행 전용 연결에 `process_data_events`를 호출 → 스레드 경계를 넘어 연결에 접근하는 것 → pika 스레드 비안전성 위반.
|
||||
- execution-loop 틱은 `self.stop.wait(interval)` 주기로 이미 동기 루프 구조 → 각 틱 시작 시 `keepalive_tick()`을 먼저 호출하면 `trex-execution` 스레드 컨텍스트에서만 실행됨.
|
||||
- keepalive 틱은 `process_data_events(time_limit=0)` 호출 — pika 문서에서 heartbeat frame 처리를 위한 권장 패턴. `time_limit=0`은 blocking 없이 socket에서 pending frame만 처리.
|
||||
- `execution_interval_sec`(기본 1.0초) < `heartbeat=30`초의 절반(15초) → 30초 이내 I/O 보장, 서버 측 idle 종료 예방 성립.
|
||||
|
||||
**별도 스레드 방식 기각 이유**: 공유 연결에 두 스레드가 동시 접근 가능 → 발행과 keepalive가 겹치면 연결 손상 또는 예외 발생. 뮤텍스로 보호해도 pika 내부 소켓 상태가 스레드 경계를 넘어 교란될 수 있어 권고되지 않음.
|
||||
|
||||
### OQ-3: DLQ 가시성 구현 방식 — Answered
|
||||
|
||||
**결정: 구조화 로그 기반 (dcy-029 관측성 자산 재사용).**
|
||||
|
||||
근거:
|
||||
- dcy-029(BAT-045/046)에서 구조화 로깅 인프라(`logging_setup.py`, `_log.error()`, correlation id, json 포맷, Loki 적재)가 이미 구축·검증됨.
|
||||
- 추가 인프라 의존성(Prometheus, StatsD, 소비자 데몬)은 본 주기 범위 밖(M3 후보).
|
||||
- 소비자 데몬 방식은 별도 상시 프로세스·새 큐·바인딩이 필요해 wire 계약 추가 가능성이 있어 교차 영향 사안(trex-orb-v1·trex-tsl-v1) 검토 필요 → 현 주기 제외.
|
||||
- 로그 기반: `_publish_dlq` 호출 시 `_log.error("DLQ 유입: reason=%s rk=%s", reason, routing_key)`, DLQ 재발행 실패 시 `_log.error("DLQ publish failed", exc_info=True)`, `_make_event_emitter` 재시도 최종 실패 시 `_log.error("event publish failed (rk=%s)", rk, exc_info=True)`.
|
||||
- 운영자는 Loki 쿼리 `{level="error"} |= "DLQ"` 또는 `|= "event publish failed"` 로 즉시 식별 가능.
|
||||
- DLQ 깊이(queue depth)는 RabbitMQ 관리 UI(`http://broker:15672`) 또는 `rabbitmqctl list_queues name messages`로 조회 — 별도 구현 불요.
|
||||
|
||||
**소비자 데몬 기각 이유**: 별도 프로세스 관리 복잡성 + 새 큐/바인딩 = wire 계약 파급 가능성. M3 검토로 이월.
|
||||
|
||||
### NFR-2: 재시도/keepalive 타임아웃 상한 — Answered
|
||||
|
||||
**결정:**
|
||||
- **단일 publish 경로 전체 블록 상한: ≤ 5초** (재연결 1회 + 재시도 1회 합산).
|
||||
- `_connect()` 내 `pika.BlockingConnection(params)`: `params.socket_timeout = 2.0` (기존 `self._timeout = 5.0`보다 강화 — 재연결용).
|
||||
- `params.blocked_connection_timeout = 2.0`.
|
||||
- `basic_publish` + `confirm_delivery` 대기: publisher confirms는 channel-level이므로 별도 타임아웃 없음(socket_timeout으로 상한됨).
|
||||
- 강제 재연결 `close()` + `_connect(2.0s)` + `basic_publish` ≤ 5초 내 완료 설계.
|
||||
- **keepalive 틱: `process_data_events(time_limit=0)`** — non-blocking, pending socket frame만 처리.
|
||||
- 실질 지연: 수 밀리초 이내, execution-loop 지연 없음.
|
||||
- **재시도 횟수: 1회** — at-least-once 의미를 최소화하고 중복 위험도 최소화.
|
||||
|
||||
근거: execution_interval_sec 기본 1.0초인데 발행 경로가 5초 이상 블록되면 매도 타이밍 손상 가능. 2초 socket_timeout + 재연결 overhead ≤ 5초가 실전 허용 범위. keepalive는 non-blocking으로 루프 지연 없음.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **재시도 중 중복 발행**: at-least-once → orb/tsl 소비자 중복 수신 가능. 전이 이벤트는 `sequence_at` last-wins 적용 가능(멱등 가능 추정, OQ-2 delivery 검증 추적).
|
||||
2. **재시도에서도 연결 실패**: `TransportUnavailableError`를 `_make_event_emitter`까지 전파 → ERROR 레벨 로그. 슬롯 전이 롤백 없음(NFR-3 — publish failure must not roll back).
|
||||
3. **keepalive 호출 중 발행 겹침**: execution-loop 단일 스레드이므로 실제 동시성 없음. `_execution_loop`에서 `keepalive_tick()` → `run_once()` 순서적 호출.
|
||||
4. **`MessagingTransport`(Protocol) 인터페이스 변경**: `keepalive_tick()` 메서드는 `RabbitMQTransport` 전용이므로 포트 인터페이스(`MessagingTransport`)에 추가하지 않는다. `daemon.py`에서 `isinstance` 체크 또는 `hasattr`로 호출하거나, `InMemoryTransport`에도 no-op 구현을 추가한다. 포트 인터페이스 확장은 wire 계약이 아니라 transport 구현 내부이므로 교차 영향 없음.
|
||||
- **결정**: `MessagingTransport` 포트에 `keepalive_tick() -> None` (default no-op) 추가. 기존 구현(`InMemoryTransport`)에 no-op 추가. daemon에서 `self.event_publisher.keepalive_tick()` 직접 호출. 포트 인터페이스 추가는 transport 어댑터 내부이므로 wire 계약과 무관.
|
||||
5. **`_publish_dlq` 내 keepalive**: DLQ 발행은 같은 channel에서 `_ensure_connected()` 후 수행. keepalive와 별도 경로이므로 간섭 없음. DLQ 발행은 no publisher-confirms이므로 block 없음.
|
||||
6. **`close()` 후 `keepalive_tick()` 호출**: `_conn is None` 체크로 no-op. 데몬 shutdown 시 stop 이벤트 세팅 후 루프 종료 → keepalive 틱 호출 없음.
|
||||
|
||||
## Architecture Impact
|
||||
|
||||
- runtime-flows (발행 재시도/keepalive/DLQ 관측 경로 추가)
|
||||
- project-structure (`keepalive_tick` 메서드, `MessagingTransport` 포트 확장 기술)
|
||||
|
||||
## Reference Doc Update Plan (REQ-094)
|
||||
|
||||
- `docs/runtime-flows.md`:
|
||||
- `슬롯 이벤트 발행/구독` 흐름: 재시도/강제 재연결 경로, idle keepalive 동작, DLQ 유입 관측 경로 추가.
|
||||
- `데몬 lifecycle`: keepalive 틱이 execution-loop 틱에 삽입됨(OQ-1 결정 반영).
|
||||
- `docs/project-structure.md`:
|
||||
- `messaging.py`(MessagingTransport 포트) 설명에 `keepalive_tick()` 추가.
|
||||
- `rabbitmq.py` 설명에 발행 재시도·keepalive 기술 추가.
|
||||
- `docs/operations.md`:
|
||||
- 신규 섹션 "DLQ 가시성/경보": `trex.events.dlq` 유입 로그 확인 방법, RabbitMQ 관리 UI queue depth 조회, 운영자 조치 절차.
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# BAT-047 Implementation
|
||||
|
||||
- Status: completed
|
||||
- Batch: BAT-047
|
||||
|
||||
## Plan Summary
|
||||
|
||||
REQ-091~094 구현. 변경은 발행 경로에 국소화하고 wire 계약·consume/RPC 경로 불변 유지.
|
||||
|
||||
1. `rabbitmq.py` — `_connect` 타임아웃 파라미터화, `_force_reconnect` 신규, `keepalive_tick` 신규, `publish_event` 재시도 로직, `_publish_dlq` ERROR 격상.
|
||||
2. `application/messaging.py` — `MessagingTransport` 포트에 `keepalive_tick()` 추가 (no-op default).
|
||||
3. `adapters/messaging/in_memory.py` — `keepalive_tick()` no-op 구현.
|
||||
4. `runtime/daemon.py` — `_make_event_emitter` WARNING→ERROR 격상, `_execution_loop` keepalive 틱 삽입.
|
||||
5. `tests/adapters/messaging/test_publish_reliability.py` — 신규 테스트 17건.
|
||||
6. `docs/` — baseline 문서 정합 (REQ-094).
|
||||
|
||||
## Changed Files
|
||||
|
||||
### 코드
|
||||
- `trex_core/adapters/messaging/rabbitmq.py`
|
||||
- `_connect(connect_timeout=None)`: 파라미터화, 기본값 `self._timeout` 유지
|
||||
- `_force_reconnect()`: 신규 — `close()` + `_connect(connect_timeout=2.0)` (NFR-2 5초 블록 예산)
|
||||
- `keepalive_tick()`: 신규 — `process_data_events(time_limit=0)`, 비소유 스레드 호출 시 잠재적 오남용 방어(소유 스레드에서만 호출 규칙은 daemon.py에서 보장)
|
||||
- `publish_event()`: 1차 실패 시 `_force_reconnect` + 재시도, `UnroutableError`는 재시도 없이 DLQ
|
||||
- `_do_basic_publish()`: 신규 내부 헬퍼 — basic_publish 로직 추출(DRY)
|
||||
- `_publish_dlq()`: WARNING → ERROR 2건(DLQ 유입 + DLQ 재발행 실패)
|
||||
- `trex_core/application/messaging.py`
|
||||
- `MessagingTransport` 프로토콜에 `keepalive_tick() -> None` 추가
|
||||
- `trex_core/adapters/messaging/in_memory.py`
|
||||
- `InMemoryTransport.keepalive_tick()` no-op 구현
|
||||
- `trex_core/runtime/daemon.py`
|
||||
- `_make_event_emitter`: WARNING → ERROR, routing key + slot_id + strategy_id 포함
|
||||
- `_execution_loop`: `self.event_publisher.keepalive_tick()` 각 틱 시작 시 호출
|
||||
|
||||
### 테스트
|
||||
- `tests/adapters/messaging/test_publish_reliability.py` (신규, 17 테스트)
|
||||
|
||||
### 문서 (REQ-094)
|
||||
- `docs/runtime-flows.md` — `슬롯 이벤트 발행/구독`, `데몬 lifecycle` 갱신
|
||||
- `docs/project-structure.md` — messaging.py, rabbitmq.py 기술 갱신
|
||||
- `docs/operations.md` — DLQ 가시성/경보 섹션 신규
|
||||
|
||||
## Execution Log
|
||||
|
||||
1. `rabbitmq.py` — `_connect`, `_force_reconnect`, `keepalive_tick`, `publish_event`, `_do_basic_publish`, `_publish_dlq` 수정/추가.
|
||||
2. `application/messaging.py` — `keepalive_tick` 포트 메서드 추가.
|
||||
3. `in_memory.py` — no-op 추가.
|
||||
4. `daemon.py` — `_make_event_emitter` + `_execution_loop` 수정.
|
||||
5. 신규 테스트 17건 작성.
|
||||
6. `python3 -m pytest tests/adapters/messaging/test_publish_reliability.py -v` → **17 passed**.
|
||||
7. `python3 -m pytest tests/ --ignore=...rabbitmq_transport... --ignore=...rabbitmq_intent_e2e... -q` → **554 passed, 40 skipped** (기존 537 + 신규 17, 회귀 0).
|
||||
8. baseline 문서 정합 (runtime-flows, project-structure, operations).
|
||||
|
||||
## Validation (중간)
|
||||
|
||||
- 신규 17건 전부 통과.
|
||||
- 기존 537 passed → 554 passed (신규 17 추가). 회귀 0.
|
||||
- `TREX_RABBITMQ_TEST_URL` 필요 통합 테스트 2건(test_rabbitmq_transport.py, test_rabbitmq_intent_e2e.py): 환경 없어 skip — 기존과 동일.
|
||||
|
||||
## Remaining Risks
|
||||
|
||||
- R-3(Medium): at-least-once 재시도 — 중복 이벤트 발행 가능. OQ-2(orb/tsl 소비자 멱등성)는 delivery 검증으로 추적. trex-core 범위 밖.
|
||||
- R-live: 실제 pika 연결 환경(TREX_RABBITMQ_TEST_URL)에서의 keepalive/재연결 동작은 통합 테스트로만 검증 가능 — CI 환경 없어 미검증. unit mock 기반으로 logic을 검증했으나 실제 broker half-open 재현은 운영 환경 배포 후 확인 필요.
|
||||
@@ -0,0 +1,36 @@
|
||||
# BAT-047: 이벤트 발행 연결 신뢰성 — idle keepalive + 재시도/재연결 + DLQ 가시성 + baseline 정합 (OPS-005)
|
||||
|
||||
- Status: release-candidate
|
||||
- Profile: standard
|
||||
- Source Discovery: dcy-031
|
||||
- Included REQ: REQ-091, REQ-092, REQ-093, REQ-094
|
||||
- Excluded REQ: 없음
|
||||
|
||||
## Summary
|
||||
- OPS-005 운영 리포트 기반 발행 신뢰성 4건을 단일 batch로 묶는다. 발행 재시도+강제 재연결(REQ-091), idle keepalive(REQ-092), DLQ 가시성+emit ERROR 경보(REQ-093)는 같은 발행 경로(`publish_event`/`_publish_dlq`/`_make_event_emitter`)를 공유하고, 동일 messaging 검증 스위트에서 검증된다. baseline 문서 정합(REQ-094)은 구현 완료 후 같은 batch에서 처리한다.
|
||||
- M2(trex-tsl-v1 라이브, 6/28 목표) 선행 조건. idle 60초 후 첫 슬롯 전이 이벤트 유실 결함 제거.
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- `trex_core/adapters/messaging/rabbitmq.py` — `publish_event` 재시도+강제 재연결(REQ-091), keepalive `process_data_events` 틱(REQ-092), `_publish_dlq` ERROR 로그(REQ-093)
|
||||
- `trex_core/runtime/daemon.py` — `_make_event_emitter` ERROR 경보 강화(REQ-093), keepalive 틱 연동(REQ-092)
|
||||
- `tests/adapters/messaging/` — half-open 시뮬레이션, 재시도 소진, idle keepalive, DLQ 경보 신규 테스트
|
||||
- `tests/application/test_messaging_event_publisher.py` — 기존 회귀 유지
|
||||
- `docs/runtime-flows.md`, `docs/project-structure.md`, `docs/operations.md` — baseline 정합(REQ-094)
|
||||
- Out of Scope:
|
||||
- 이벤트 영속 outbox(옵션 C) — M3 후보
|
||||
- consumer(orb/tsl) idempotency 구현
|
||||
- RPC client(`call_rpc`) 연결 정책
|
||||
- `docs/interface-contract.md` 변경 — AMQP wire 계약 불변
|
||||
|
||||
## Documents
|
||||
- Planning: [planning.md](./planning.md)
|
||||
- Design: [design.md](./design.md)
|
||||
- Implementation: [implementation.md](./implementation.md)
|
||||
- Verification: [verification.md](./verification.md)
|
||||
|
||||
## Notes
|
||||
- R-1(High): M1 라이브 trex-core — 변경은 발행 경로에 국소화, wire 계약·consume/RPC 경로 불변. opus qc-reviewer 강제(dcy-031 §10 CONFIRM①).
|
||||
- R-2(Medium): pika 스레드 비안전성 — keepalive I/O는 발행 연결 소유 스레드에서만 실행(OQ-1 설계 확정).
|
||||
- R-3(Medium): 재시도 at-least-once 중복 가능 — OQ-2는 delivery 검증으로 추적(소비자 멱등성).
|
||||
- NFR-2 타임아웃 수치는 design 단계에서 확정.
|
||||
@@ -0,0 +1,67 @@
|
||||
# BAT-047 Planning
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-047
|
||||
- Profile: standard
|
||||
|
||||
## Included REQ
|
||||
|
||||
- REQ-091: 이벤트 발행 재시도 + 강제 재연결 (idle 후 첫 발행 무유실)
|
||||
- REQ-092: 발행 전용 연결 idle keepalive (서버 측 heartbeat 사망 예방)
|
||||
- REQ-093: DLQ 가시성/경보 + emit 실패 경보 (trex.events.dlq 관측성)
|
||||
- REQ-094: 이벤트 발행 신뢰성 baseline 문서 정합 (runtime-flows / project-structure / operations 갱신)
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
구현 순서는 의존성과 위험도 기준으로 아래와 같다.
|
||||
|
||||
1. **REQ-092 keepalive 설계 확정(OQ-1)**: draft-batch-design에서 pika 스레드 소유 모델 기준으로 keepalive 실행 위치(별도 스레드 vs. execution-loop 틱) 결정. 이 결정이 REQ-092 구현 전제.
|
||||
|
||||
2. **REQ-093 DLQ 가시성 방식 결정(OQ-3)**: draft-batch-design에서 dcy-029 로깅 자산 재사용 가능성 검토 후 메트릭/로그/소비자 중 택1. 이 결정이 REQ-093 DLQ 유입 관측 구현 전제.
|
||||
|
||||
3. **REQ-093 NFR-2 타임아웃 수치 확정**: design에서 단일 publish 경로 전체 블록 상한 수치 결정.
|
||||
|
||||
4. **구현(run-batch-implementation)**: 설계 결정 순서대로
|
||||
- Phase A: `rabbitmq.py` `publish_event` 재시도+강제 재연결(REQ-091) + `_publish_dlq` ERROR 로그(REQ-093)
|
||||
- Phase B: keepalive 구현(REQ-092) — OQ-1 결정 결과 적용
|
||||
- Phase C: `daemon.py` `_make_event_emitter` ERROR 경보 강화(REQ-093)
|
||||
- Phase D: 신규 테스트 작성 (half-open/재시도/keepalive/DLQ/emit ERROR AC 커버)
|
||||
|
||||
5. **REQ-094 baseline 문서 정합**: 구현 완료 후 `runtime-flows.md`, `project-structure.md`, `operations.md` 갱신.
|
||||
|
||||
선행 조건: REQ-091~093은 코드 범위가 분리되어 있어 병렬 구현 가능하나, keepalive(REQ-092)와 emit 경보(REQ-093)는 OQ-1/OQ-3 설계 결정이 전제여서 설계 완료 후 시작.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Internal:
|
||||
- `trex_core/adapters/messaging/rabbitmq.py` — 변경 대상 핵심 파일 (BAT-007에서 도입됨)
|
||||
- `trex_core/runtime/daemon.py` — `_make_event_emitter` 변경 대상
|
||||
- `tests/adapters/messaging/` — 기존 messaging 회귀 스위트(현재 537 passed) 전체 통과 유지 필수
|
||||
- `tests/application/test_messaging_event_publisher.py` — 기존 회귀 유지
|
||||
- dcy-029(BAT-045/046) — 구조화 로깅 인프라 자산(OQ-3 재사용 검토 대상)
|
||||
- External:
|
||||
- pika BlockingConnection API(`process_data_events`, `close`, `BlockingConnection`) — 버전 고정 (pyproject.toml 기준)
|
||||
- RabbitMQ broker — 통합 테스트는 `TREX_RABBITMQ_TEST_URL` skip(CI 환경 없으면 skip, unit은 mock 기반)
|
||||
|
||||
## Design Gate
|
||||
|
||||
- Design Required Before Implementation: yes
|
||||
- Reason:
|
||||
- OQ-1(keepalive 실행 위치): pika 스레드 비안전성이 명시적 스레드 소유 모델 결정을 요구한다. 설계 없이 구현하면 다른 스레드에서 I/O 호출 시 연결 손상 리스크가 있다.
|
||||
- OQ-3(DLQ 가시성 방식): 메트릭/로그/소비자 데몬 중 선택이 구현 구조를 결정한다. 특히 소비자 데몬 방식이면 별도 스레드/모듈이 추가된다.
|
||||
- NFR-2(타임아웃 수치): execution-loop 블록 상한 수치가 코드에 상수로 박힌다. 설계에서 확정해야 AC-3 기준이 명확해진다.
|
||||
- architecture impact가 `runtime-flows`(keepalive 틱, 재시도 경로)이므로 standard profile design이 필요하다.
|
||||
|
||||
## Milestones
|
||||
|
||||
1. draft-batch-design 완료 — OQ-1(keepalive 위치), OQ-3(DLQ 방식), NFR-2(타임아웃 수치) 확정
|
||||
2. run-batch-implementation 완료 — 신규 테스트 포함, 기존 회귀 537 passed 유지
|
||||
3. draft-batch-verification 완료 — AC별 커버 확인, baseline 문서 정합 확인
|
||||
4. QC reviewer 검증 대기 (confirm-batch-verification은 팀장 게이트)
|
||||
|
||||
## Risks
|
||||
|
||||
- R-1(High): M1 라이브 trex-core — wire 계약·consume/RPC 경로 회귀 시 orb(라이브)·tsl(M2)에 즉시 영향. 완화: 변경을 `publish_event`/`_publish_dlq`/`_make_event_emitter` 경로에 국소화. `declare_topology`/`consume_rpc_forever`/`consume_events_forever`/`call_rpc` 경로 불변. 기존 회귀 스위트 전체 통과 강제.
|
||||
- R-2(Medium): pika 스레드 비안전성 — keepalive가 발행 전용 연결 소유 스레드 밖에서 실행되면 연결 손상. 완화: OQ-1을 design에서 확정, AC-2/AC-3(스레드 안전성) 테스트로 검증.
|
||||
- R-3(Medium): 재시도 at-least-once — 중복 이벤트 발행 가능. 완화: OQ-2는 delivery 검증(소비자 orb/tsl 멱등성 확인)으로 추적. 전이 이벤트는 멱등 적용 가능 추정.
|
||||
- R-4(Low): keepalive `process_data_events(time_limit=0)` 틱이 execution-loop와 같은 스레드에서 실행될 경우 루프 지연 유발 가능. 완화: NFR-2 타임아웃 수치를 design에서 확정, AC-4(블록 상한) 테스트로 검증.
|
||||
@@ -0,0 +1,131 @@
|
||||
# BAT-047 Verification
|
||||
|
||||
- Status: approved (confirm-batch-verification, 팀장 2026-06-19)
|
||||
- Batch: BAT-047
|
||||
- Verified By: trex-core-dev (draft-batch-verification → QC PASS-WITH-NOTES 시정 후 재검증)
|
||||
- Verified At (KST): 2026-06-19 (최초), 2026-06-19 (QC 시정 재검증)
|
||||
- Confirmed By: 팀장 (독립 opus qc-reviewer PASS-WITH-NOTES + 시정 2건 완료 + 팀장 직접 재확인)
|
||||
|
||||
## Human Approval Memo (팀장 confirm-batch-verification)
|
||||
- 독립 opus qc-reviewer 판정 = PASS-WITH-NOTES. 반환 2건 모두 시정 완료:
|
||||
- ① ruff F401 3건 제거 → 팀장 직접 재실행 `All checks passed!` 확인.
|
||||
- ② REQ-093 AC-3 테스트가 프로덕션 `Daemon._make_event_emitter()`를 실제 호출하도록 재작성 → 팀장 코드 확인(L398 `object.__new__(Daemon)` → L404 `_make_event_emitter()` → L422 `emit(...)`).
|
||||
- 팀장 직접 전체 재실행: **554 passed / 47 skipped / 0 실패** (회귀 0).
|
||||
- 수용한 잔여 한계(blocker 아님, release 노트에 명시할 것):
|
||||
- half-open / broker 재시작 / idle 60초 생존은 mock unit으로만 검증. 실 broker 통합(`TREX_RABBITMQ_TEST_URL`)은 환경 미설정으로 skip → M1 운영 또는 broker 포함 CI로 통합검증 위임.
|
||||
- NFR-2 "publish 경로 블록 ≤5초"는 재연결(2.0s)+재시도(2.0s) 구간에 엄밀 성립. 1차 실패 시도가 원 socket_timeout(5.0s)에 묶이면 최악 케이스 초과 가능 → 필요 시 후속 REQ로 초기 publish timeout 강화.
|
||||
- NFR-1(M1 라이브 무회귀): wire 계약·consume/RPC·토폴로지·`interface-contract.md`(diff 0) 불변 확인 — qc-reviewer 코드 추적 + 팀장 확인.
|
||||
|
||||
## Test Run Summary
|
||||
|
||||
> **정정 (QC PASS-WITH-NOTES 시정 후)**: 최초 verification.md에서 "ruff clean"으로 기재했으나, QC 독립 검증에서 ruff F401 3건(threading, unittest.mock.call, trex_core.runtime.daemon.Daemon)이 검출됐다. 또한 REQ-093 AC-3 테스트가 프로덕션 `_make_event_emitter` 경로를 호출하지 않고 emit 클로저를 테스트 본문에 직접 복제하는 구조였다. 이하는 시정 후 실제 재실행 결과다.
|
||||
|
||||
### 시정 후 재실행 결과
|
||||
|
||||
```
|
||||
명령: python3 -m ruff check .
|
||||
결과: All checks passed!
|
||||
```
|
||||
|
||||
```
|
||||
명령: python3 -m pytest tests/ --ignore=tests/adapters/messaging/test_rabbitmq_transport.py \
|
||||
--ignore=tests/adapters/messaging/test_rabbitmq_intent_e2e.py -q
|
||||
결과: 554 passed, 40 skipped in 1.51s
|
||||
기존 baseline: 537 passed (BAT-046 기준)
|
||||
신규 추가: 17 passed (tests/adapters/messaging/test_publish_reliability.py)
|
||||
회귀: 0건
|
||||
```
|
||||
|
||||
## Acceptance Criteria Mapping
|
||||
|
||||
### REQ-091: 이벤트 발행 재시도 + 강제 재연결
|
||||
|
||||
| AC | 내용 | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC-1 | half-open → 재연결+재시도 → 발행 성공 | `TestPublishRetryOnHalfOpen::test_half_open_first_attempt_fails_retry_succeeds` PASSED | PASS |
|
||||
| AC-2 | 재시도 소진 → `TransportUnavailableError` 전파 | `TestPublishRetryExhausted::test_retry_exhausted_raises_transport_unavailable` PASSED | PASS |
|
||||
| AC-3 | 타임아웃 상한 확인 (`connect_timeout=2.0`) | `TestForceReconnectTimeout::test_force_reconnect_uses_2s_timeout` PASSED | PASS |
|
||||
| AC-4 | 정상 발행(연결 살아 있음) — 재시도 경로 미호출 | `TestNormalPublishNoRetry::test_normal_publish_does_not_call_force_reconnect` PASSED | PASS |
|
||||
| AC-5 | consume/RPC 경로 무회귀 | `TestConsumePaths::test_rabbitmq_transport_has_consume_methods` PASSED + 전체 회귀 554 passed 0 회귀 | PASS |
|
||||
| AC-6 | broker 재시작 후 발행 복구 | `TestBrokerRestartScenario::test_broker_restart_publish_recovers` PASSED | PASS |
|
||||
|
||||
### REQ-092: 발행 전용 연결 idle keepalive
|
||||
|
||||
| AC | 내용 | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC-1 | idle 후 연결 생존 (keepalive가 `process_data_events` 호출) | `TestKeepaliveTick::test_keepalive_tick_calls_process_data_events_on_live_connection` PASSED | PASS |
|
||||
| AC-2 | 발행 소유 스레드에서만 I/O (design 결정: execution-loop 틱에 삽입) | OQ-1 design 문서에 Answered 기록. daemon.py `_execution_loop` 내 `keepalive_tick()` 호출 코드 diff 확인. pika 스레드 경계 불침범 | PASS |
|
||||
| AC-3 | 동시 발행+keepalive 충돌 없음 | `TestKeepaliveAndPublishSameThread::test_keepalive_then_publish_sequential_no_error` PASSED (단일 스레드 순차 호출) | PASS |
|
||||
| AC-4 | keepalive 비블록 (`time_limit=0`) | `TestKeepaliveTimeLimitZero::test_keepalive_uses_time_limit_zero` PASSED | PASS |
|
||||
| AC-5 | consume/RPC 경로 무회귀 | `TestKeepaliveDoesNotAffectConsumePaths::test_keepalive_tick_not_present_on_consume_rpc_forever_signature` PASSED | PASS |
|
||||
| AC-6 | OQ-1 종료 조건 (design 문서에 keepalive 위치 결정) | `design.md` "OQ-1: Answered — execution-loop 틱 삽입" 기록 완료 | PASS |
|
||||
|
||||
### REQ-093: DLQ 가시성/경보 + emit 실패 경보
|
||||
|
||||
| AC | 내용 | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC-1 | unroutable → DLQ 유입 + 관측 지표 노출 | `TestDlqVisibilityOnUnroutable::test_unroutable_calls_publish_dlq_and_logs_error` PASSED + `test_publish_dlq_logs_error_on_entry` PASSED | PASS |
|
||||
| AC-2 | DLQ 깊이 파악 가능 | 운영자 RabbitMQ 관리 UI + `rabbitmqctl list_queues` 조회 절차 `docs/operations.md` §4에 기재 | PASS |
|
||||
| AC-3 | emit 최종 실패 → ERROR 로그(rk+slot_id+strategy 포함) | `TestEmitErrorOnFinalPublishFailure::test_emit_logs_error_on_transport_unavailable` PASSED — **시정 후**: 복제 클로저 제거, `object.__new__(Daemon)`으로 stub 생성 후 `event_publisher`만 주입해 실제 `Daemon._make_event_emitter()`가 반환한 emit 클로저 직접 호출로 재작성. 프로덕션 경로 회귀 가드 확립. | PASS |
|
||||
| AC-4 | `_publish_dlq` 재발행 실패 → ERROR 로그 | `TestDlqPublishFailureLogsError::test_dlq_publish_failure_logs_error_not_warning` PASSED | PASS |
|
||||
| AC-5 | OQ-3 종료 조건 (design 문서에 DLQ 가시성 방식 결정) | `design.md` "OQ-3: Answered — 구조화 로그 기반 (dcy-029 자산 재사용)" 기록 완료 | PASS |
|
||||
| AC-6 | consume/RPC 경로 무회귀 | `TestConsumePaths` PASSED + 전체 회귀 554 passed 0 회귀 | PASS |
|
||||
|
||||
### REQ-094: baseline 문서 정합
|
||||
|
||||
| AC | 내용 | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC-1 | `runtime-flows.md` 발행/keepalive 경로 정합 | `docs/runtime-flows.md` — `슬롯 이벤트 발행/구독` 재시도/재연결/keepalive/DLQ 관측 경로 기술 추가 확인 | PASS |
|
||||
| AC-2 | OQ-1 결정(keepalive 위치) `runtime-flows.md` 반영 | `데몬 lifecycle` flow에 `trex-execution` 틱 시작 시 `keepalive_tick()` 호출 기술 추가 확인 | PASS |
|
||||
| AC-3 | OQ-3 결정(DLQ 가시성) `operations.md` DLQ 절차 신규 기재 | `docs/operations.md` §4 "DLQ 가시성 / 이벤트 발행 경보" 섹션 신규 생성 확인 | PASS |
|
||||
| AC-4 | `project-structure.md` 신규 컴포넌트 반영 | `messaging.py`(keepalive_tick), `rabbitmq.py`(재시도+keepalive), `daemon.py`(keepalive/ERROR 격상), 신규 테스트 파일 기술 추가 확인 | PASS |
|
||||
| AC-5 | `interface-contract.md` 불변 | diff 확인 — 본 batch에서 `docs/interface-contract.md` 변경 없음 (AMQP 토폴로지·큐·exchange·라우팅 키 불변) | PASS |
|
||||
|
||||
## Wire 계약 무회귀 검증 (NFR-1)
|
||||
|
||||
- `declare_topology` 경로: 변경 없음.
|
||||
- `consume_rpc_forever` / `consume_events_forever` / `call_rpc` 경로: 변경 없음.
|
||||
- `EVENTS_EXCHANGE`, `EVENTS_DLQ_EXCHANGE`, `EVENTS_DLQ_QUEUE` 토폴로지: 변경 없음.
|
||||
- 발행 routing key 형식(`{event_type}.{strategy_id}.{symbol}`): 변경 없음.
|
||||
- 페이로드 스키마: 변경 없음.
|
||||
- `docs/interface-contract.md`: 변경 없음.
|
||||
|
||||
## baseline 문서 동기화 (Architecture Impact: runtime-flows)
|
||||
|
||||
- `docs/runtime-flows.md` — 갱신 완료 (Last Updated: 2026-06-19 BAT-047)
|
||||
- `docs/project-structure.md` — 갱신 완료 (Last Updated: 2026-06-19 BAT-047)
|
||||
- `docs/operations.md` — DLQ 절차 신규 기재 완료
|
||||
|
||||
## 미검증 항목 (Blocking Issues 없음, 잔여 위험으로 분류)
|
||||
|
||||
- **실제 broker half-open 재현**: `TREX_RABBITMQ_TEST_URL` 환경이 없어 통합 테스트(`test_rabbitmq_transport.py`, `test_rabbitmq_intent_e2e.py`) skip됨 (기존 skip과 동일 조건). 신규 테스트는 모두 mock 기반 unit test로 로직을 검증함.
|
||||
- **OQ-2 소비자 멱등성**: orb/tsl 재시도 중복 이벤트 수신 시 멱등성 확인은 각 repo 별도 추적(본 batch 밖). trex-core 범위 완료.
|
||||
|
||||
## QC PASS-WITH-NOTES 시정 내역 (2026-06-19 재검증)
|
||||
|
||||
QC opus 검증에서 지적된 2건을 시정했다.
|
||||
|
||||
**시정 1 — ruff F401 3건 제거**
|
||||
- `tests/adapters/messaging/test_publish_reliability.py` L28: `import threading` 제거
|
||||
- `tests/adapters/messaging/test_publish_reliability.py` L29: `from unittest.mock import MagicMock, call, patch` → `call` 제거
|
||||
- `trex_core.runtime.daemon.Daemon` import는 시정 2에서 실제 사용하도록 재작성해 F401 해소
|
||||
|
||||
**시정 2 — REQ-093 AC-3 테스트를 프로덕션 경로로 재작성**
|
||||
- 구: 테스트 본문에 emit 클로저 직접 복제 (프로덕션 `_make_event_emitter` 미호출)
|
||||
- 신: `object.__new__(Daemon)`으로 stub 인스턴스 생성 → `event_publisher`만 mock 주입 → `daemon_stub._make_event_emitter()`가 반환한 emitter를 그대로 호출
|
||||
- 효과: `daemon.py` L247-253의 `_log.error(...)` 경로를 실제로 타므로, 해당 코드가 제거/변경되면 테스트가 실패해 회귀를 가드함
|
||||
|
||||
**재검증 실제 결과**
|
||||
- `python3 -m ruff check .` → `All checks passed!`
|
||||
- `python3 -m pytest tests/ --ignore=... -q` → `554 passed, 40 skipped in 1.51s`, 회귀 0
|
||||
|
||||
## Ready for confirm-batch-verification
|
||||
|
||||
- 신규 테스트: 17 passed
|
||||
- 전체 테스트: 554 passed, 40 skipped (기존 대비 +17, 회귀 0)
|
||||
- ruff: clean (F401 3건 시정 완료, 실제 실행 결과)
|
||||
- AC 커버: REQ-091 AC-1~6, REQ-092 AC-1~6, REQ-093 AC-1~6, REQ-094 AC-1~5 전부 PASS 또는 evidence 있음
|
||||
- wire 계약 무회귀: PASS
|
||||
- baseline 문서 정합: PASS
|
||||
- Blocking Issues: 없음
|
||||
- Ready for Release: false (멈춤 — confirm-batch-verification은 팀장 게이트)
|
||||
@@ -54,3 +54,4 @@
|
||||
| BAT-044 | standard | released | REQ-083, REQ-070 (CHG-20260616-01) | dcy-030 (issue #28) | [bat-044](./bat-044_20260616_slippage-tick-size-snapping/) |
|
||||
| BAT-045 | standard | released | REQ-084, REQ-085, REQ-087 | dcy-029 (issue #29, P0) | [bat-045](./bat-045_20260616_observability-logging-foundation/) |
|
||||
| BAT-046 | standard | released | REQ-086, REQ-088, REQ-089, REQ-090 | dcy-029 (issue #29, P1) | [bat-046](./bat-046_20260616_observability-classification-pnl-runbook/) |
|
||||
| BAT-047 | standard | release-candidate | REQ-091, REQ-092, REQ-093, REQ-094 | dcy-031 | [bat-047](./bat-047_20260619_event-publisher-connection-reliability/) |
|
||||
|
||||
+58
-1
@@ -94,7 +94,62 @@ grep "event=order_reject" /tmp/trex-core.log # 거부 — ERROR 레벨
|
||||
|
||||
---
|
||||
|
||||
## 4. 디버깅 / 트러블슈팅
|
||||
## 4. DLQ 가시성 / 이벤트 발행 경보 (REQ-093 / dcy-031 / BAT-047)
|
||||
|
||||
trex-core는 `trex.events.dlq`(durable) 큐와 구조화 로그를 통해 이벤트 발행 실패를 운영자에게 노출한다.
|
||||
|
||||
### DLQ 유입 원인
|
||||
|
||||
| 원인 | 경로 | 로그 |
|
||||
|---|---|---|
|
||||
| 구독자 없는 이벤트(unroutable) | `UnroutableError` → `_publish_dlq(reason="unroutable")` | ERROR `DLQ entry: reason=unroutable rk=...` |
|
||||
| DLQ 재발행 자체 실패 | `_publish_dlq` 내 exception | ERROR `DLQ publish failed (rk=...)` |
|
||||
| 발행 최종 실패(재연결+재시도 후) | `_make_event_emitter` → `TransportUnavailableError` | ERROR `event publish failed (rk=... slot_id=... strategy=...)` |
|
||||
|
||||
### 로그 기반 경보 (Loki / grep)
|
||||
|
||||
```bash
|
||||
# DLQ 유입 확인
|
||||
grep '"level":"error"' /tmp/trex-core.log | grep "DLQ"
|
||||
# 또는 text 포맷
|
||||
grep "ERROR.*DLQ" /tmp/trex-core.log
|
||||
|
||||
# 발행 최종 실패 확인
|
||||
grep "event publish failed" /tmp/trex-core.log
|
||||
```
|
||||
|
||||
Loki LogQL:
|
||||
```logql
|
||||
{job="trex-core"} | json | level="error" | line_contains "DLQ"
|
||||
{job="trex-core"} | json | level="error" | line_contains "event publish failed"
|
||||
```
|
||||
|
||||
### RabbitMQ queue depth 확인
|
||||
|
||||
```bash
|
||||
# rabbitmqctl로 DLQ 깊이 조회
|
||||
rabbitmqctl list_queues name messages | grep dlq
|
||||
|
||||
# RabbitMQ 관리 UI: http://<broker>:15672 → Queues → trex.events.dlq
|
||||
```
|
||||
|
||||
### 운영자 조치 절차
|
||||
|
||||
1. **DLQ 유입 경보 수신**: 로그 `ERROR ... DLQ entry` 발견.
|
||||
2. **원인 분류**:
|
||||
- `reason=unroutable`: 해당 strategy_id의 전략 데몬(orb/tsl)이 꺼져 있거나 큐 바인딩이 없는 경우. 전략 데몬 기동 상태 확인.
|
||||
- `DLQ publish failed`: RabbitMQ 자체 연결 문제. broker 상태 확인.
|
||||
- `event publish failed`: 발행 연결 재연결+재시도 후에도 실패. broker 재시작 또는 네트워크 장애. trex-core 재기동 검토.
|
||||
3. **DLQ 내용 소비**(선택): 적체된 메시지는 수동 drain 또는 strategy 재기동 후 catch-up.
|
||||
4. **임계값 기준**: DLQ depth > 0 = 경보 대상. depth > 100 = 즉시 조치 권고.
|
||||
|
||||
### idle heartbeat 예방 (REQ-092)
|
||||
|
||||
trex-core는 발행 연결의 idle 종료를 예방하기 위해 `trex-execution` 루프 각 틱(기본 1초)마다 `event_publisher.keepalive_tick()`(`process_data_events(time_limit=0)`)을 호출한다. `heartbeat=30s` 설정 대비 충분한 I/O 주기를 보장한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 디버깅 / 트러블슈팅
|
||||
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
@@ -118,6 +173,8 @@ psql "$TREX_POSTGRESQL_DSN" -c "SELECT slot_id, from_state, to_state, reason, ac
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
- `docs/interface-contract.md` — RabbitMQ 토폴로지 + 메시지 payload + reason/actor 어휘
|
||||
- `docs/runtime-flows.md` — 흐름 다이어그램(스냅샷/intent RPC/이벤트/집행 루프/로깅)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||
- Status: draft
|
||||
- Owner: project maintainers
|
||||
- Last Updated (KST): 2026-06-10 (BAT-031 — dcy-021 포지션 사이징)
|
||||
- Last Updated (KST): 2026-06-19 (BAT-047 — dcy-031 이벤트 발행 연결 신뢰성)
|
||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물)
|
||||
|
||||
## Project Summary
|
||||
@@ -74,7 +74,7 @@
|
||||
│ │ ├── execution.py # ExecutionService (dispatch·poll·transition·reconcile + ledger enqueue + event emit hook)
|
||||
│ │ ├── ledger_port.py # LedgerPort + LedgerTrade/LedgerWriteResult/예외 (BAT-006)
|
||||
│ │ ├── ledger_outbox.py # LedgerOutbox Protocol + LedgerWriter + InMemoryLedgerOutbox (BAT-006)
|
||||
│ │ ├── messaging.py # MessagingTransport Protocol + DTO + TransportHealth (BAT-007)
|
||||
│ │ ├── messaging.py # MessagingTransport Protocol + DTO + TransportHealth + keepalive_tick() no-op (BAT-007, BAT-047/REQ-092)
|
||||
│ │ ├── messaging_handlers.py # intent/snapshot/pause RPC handlers (+update_thresholds REQ-040) + slot event payload builder (BAT-007/BAT-008/BAT-011)
|
||||
│ │ ├── portfolio.py # PortfolioHubReadPort + SnapshotStore + PauseStore + envelope builder (BAT-008)
|
||||
│ │ ├── reconciliation.py # reconcile_positions + reconcile_core_holdings(REQ-062) (BAT-008/029)
|
||||
@@ -86,12 +86,12 @@
|
||||
│ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
|
||||
│ │ ├── broker/ # paper.py(결정적 async 더블 + 현재가 stub) · kis.py(KIS Open API + 잔고조회 + 현재가 polling) (BAT-004/BAT-008/BAT-011)
|
||||
│ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006)
|
||||
│ │ ├── messaging/ # topology.py(동적 큐명 빌더 + trex.direct/trex.broadcast exchange, BAT-013) · in_memory.py(stub) · rabbitmq.py(pika 동기, declare_topology(instance_key)) (BAT-007/BAT-013)
|
||||
│ │ ├── messaging/ # topology.py(동적 큐명 빌더 + trex.direct/trex.broadcast exchange, BAT-013) · in_memory.py(stub, keepalive_tick no-op) · rabbitmq.py(pika 동기, declare_topology(instance_key), publish_event 재시도+강제 재연결, keepalive_tick, _publish_dlq ERROR 로그) (BAT-007/BAT-013/BAT-047)
|
||||
│ │ ├── portfolio/ # hub_read.py(HTTP) · stub_read.py(stub) · pause_store.py(Postgres) · killswitch_store.py(day-scoped, BAT-012) (BAT-008)
|
||||
│ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · ops_checks.py(ledger-gap, transition_log 기반) · in_memory_intent_store.py · migrate.py · sql/(001~009, 010_literal_inactive_status.sql, 011_drop_pool_model_columns.sql)
|
||||
│ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
|
||||
│ ├── config.py # DaemonConfig(+instance_key, max_active_slots) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013/028/030)
|
||||
│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown
|
||||
│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown. _execution_loop 각 틱 시작 시 event_publisher.keepalive_tick() 호출(REQ-092). _make_event_emitter 발행 최종 실패 시 ERROR 로그(REQ-093).
|
||||
│ ├── reset.py # trex-core-reset: slot-core 테이블 wipe + 자산 중심 재초기화 (REQ-064, BAT-030)
|
||||
│ ├── logging_setup.py # 구조화 로깅(ISO8601 KST·레벨·Loki JSON·correlation id) + configure_logging (BAT-045 / REQ-084, dcy-029)
|
||||
│ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit/cancel + report(REQ-088) (BAT-008/020/046 / REQ-032)
|
||||
@@ -100,7 +100,8 @@
|
||||
├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007)
|
||||
├── application/ # test_intent_concurrency · test_execution · test_execution_ledger · test_ledger_outbox · test_messaging_intent_consumer · test_messaging_snapshot_consumer · test_messaging_event_publisher
|
||||
├── runtime/ # test_config_dotenv · test_daemon_no_http_surface (BAT-007 HTTP 표면 0 회귀 가드)
|
||||
└── adapters/{broker,ledger,messaging,persistence}/ # paper/KIS · in_memory/portfolio-hub ledger · stub/RabbitMQ transport (게이트) · PG repo/migration/outbox
|
||||
├── adapters/{broker,ledger,messaging,persistence}/ # paper/KIS · in_memory/portfolio-hub ledger · stub/RabbitMQ transport (게이트) · PG repo/migration/outbox
|
||||
│ └── messaging/test_publish_reliability.py # BAT-047 신규 — publish 재시도/keepalive/DLQ 가시성 단위 테스트 (mock 기반, 17건)
|
||||
```
|
||||
|
||||
> 참고: domain(BAT-001)은 무의존 코어. application/adapters/runtime 은 BAT-002 에서 추가, BAT-004 에서 broker/execution 레이어 확장, BAT-006 에서 ledger 레이어 추가, BAT-007 에서 messaging 레이어(application MessagingTransport + adapters/messaging + sync+thread 데몬) 추가와 HTTP 어댑터 제거. 외부 의존(psycopg/httpx/redis/pika)은 adapters/runtime 에만 격리(NFR-1, import 스윕으로 강제). 브로커 주문은 BrokerPort 단일 세션(불변식 B), 슬롯 쓰기는 SlotService 단일 writer(불변식 A), ledger 기록은 집행 경로 단일 주체(NFR-3), 메시지 transport 는 MessagingTransport 단일 경계.
|
||||
|
||||
+18
-8
@@ -4,7 +4,7 @@
|
||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||
- Status: draft
|
||||
- Owner: project maintainers
|
||||
- Last Updated (KST): 2026-06-10 (BAT-031 — dcy-021 포지션 사이징)
|
||||
- Last Updated (KST): 2026-06-19 (BAT-047 — dcy-031 이벤트 발행 연결 신뢰성)
|
||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
|
||||
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
- SlotRepository (Protocol) :: 저장소 구현체 주입 경계
|
||||
- MessagingTransport (Protocol) :: 메시지 transport 추상화 (RabbitMQ / InMemory stub)
|
||||
|
||||
## Flow: `데몬 lifecycle` (BAT-002, BAT-007)
|
||||
## Flow: `데몬 lifecycle` (BAT-002, BAT-007, BAT-047)
|
||||
|
||||
1. `trex-core-daemon --config <path>.toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `max_active_slots`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅·DB 행 스코프 분리(REQ-056).
|
||||
2. setup: `apply_migrations(dsn)`(멱등; 010 inactive CHECK·011 컬럼 drop 포함) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. (일일 preallocation 은 BAT-030 에서 폐기 — 슬롯 생성은 universe reconcile 이 담당.)
|
||||
3. start: 7개 백그라운드 스레드 동시 기동
|
||||
- `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062)
|
||||
- `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062). **각 틱 시작 시 `event_publisher.keepalive_tick()` 호출** (REQ-092 — pika 스레드 안전 규칙: 발행 연결 소유 스레드에서만 I/O). 틱 간격(기본 1s) < heartbeat/2(15s) → 서버 측 idle 종료 예방.
|
||||
- `trex-ledger` :: ledger outbox 드레인 (REQ-019)
|
||||
- `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040)
|
||||
- `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036)
|
||||
@@ -62,15 +62,25 @@
|
||||
6. ack-after-commit 시퀀스: durable commit 이 ack 보다 먼저이므로 ack 전 크래시 시 재전달돼도 (3) 의 dedup 으로 재실행되지 않는다.
|
||||
7. 체결/종료는 응답이 아니라 후속 이벤트(아래)로 전달 — admission 동기 + fill 비동기 2단계 시맨틱.
|
||||
|
||||
## Flow: `슬롯 이벤트 발행/구독` (RabbitMQ `trex.events`, BAT-007)
|
||||
## Flow: `슬롯 이벤트 발행/구독` (RabbitMQ `trex.events`, BAT-007, BAT-047)
|
||||
|
||||
1. 체결: execution 루프가 `pending_buy→holding`(또는 `pending_sell→closed`)을 commit + ledger outbox enqueue (REQ-018/019).
|
||||
2. 같은 스레드에서 `event_publisher.publish_event(rk, payload)` 호출.
|
||||
2. 같은 스레드에서 `event_publisher.publish_event(rk, payload)` 호출 (REQ-091 재시도 정책 포함, BAT-047).
|
||||
- routing key: `{event_type}.{strategy_id}.{symbol}` (예: `slot_filled_buy.A.005930`).
|
||||
- payload: 현재 상태 + `sequence_at`(슬롯 `updated_at` iso) 운반 → 수신자는 last-wins 적용 가능 (순서 보증 불요).
|
||||
- publisher confirms 활성, 발행 실패는 로그 + DLQ 라우팅(`trex.events.dlq`). publish 실패가 슬롯 전이를 롤백하지 않는다.
|
||||
3. 전략별 durable queue(`trex.events.strategy.{strategy_id}`)는 `*.{strategy_id}.#` 패턴으로 바인딩 → broker 단 필터로 자기 자산 이벤트만 수신.
|
||||
4. 전략 down 중 적체된 메시지는 catch-up 으로 복구; TTL(1h) / max-length(10k) 초과는 `drop-head` + 후속 스냅샷 RPC reconcile.
|
||||
- publisher confirms 활성. `is_open=True`를 맹신하지 않음 — 발행 실패 시(half-open 포함) 강제 재연결(`close`+`_connect(timeout=2s)`) 후 1회 재시도. 재시도도 실패하면 `TransportUnavailableError` 전파.
|
||||
- unroutable(구독자 없음): `UnroutableError` → `_publish_dlq`로 `trex.events.dlq`(durable)에 보존. DLQ 유입 시 ERROR 로그 발행(REQ-093).
|
||||
- publish 최종 실패는 ERROR 로그(routing key + slot_id + strategy_id 포함)로 경보 — 슬롯 전이 롤백 없음(NFR-3).
|
||||
3. idle keepalive: `trex-execution` 스레드가 각 틱 시작 시 `event_publisher.keepalive_tick()`(`process_data_events(time_limit=0)`)을 호출 → heartbeat 주기 내 I/O 보장 → 서버 측 idle 종료 예방 (1차 방어선, REQ-092). 발행 재시도는 2차 복구 방어선(REQ-091).
|
||||
4. 전략별 durable queue(`trex.events.strategy.{strategy_id}`)는 `*.{strategy_id}.#` 패턴으로 바인딩 → broker 단 필터로 자기 자산 이벤트만 수신.
|
||||
5. 전략 down 중 적체된 메시지는 catch-up 으로 복구; TTL(1h) / max-length(10k) 초과는 `drop-head` + 후속 스냅샷 RPC reconcile.
|
||||
|
||||
### DLQ 관측 경로 (`trex.events.dlq`, REQ-093)
|
||||
|
||||
- unroutable 이벤트 → `_publish_dlq(reason="unroutable")` → ERROR 로그 + `trex.events.dlq`(durable) 유입.
|
||||
- 발행 최종 실패 → `_make_event_emitter` ERROR 로그(경보). DLQ 유입 없음(연결 자체가 없는 경우).
|
||||
- `_publish_dlq` 재발행 자체 실패 시 → ERROR 로그(`DLQ publish failed rk=...`).
|
||||
- 운영자 확인: 로그 `level=ERROR message=~"DLQ"` 또는 RabbitMQ 관리 UI queue depth `trex.events.dlq` 조회.
|
||||
|
||||
## Flow: `일일 스냅샷 refresh` (BAT-008 / REQ-030)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-094: 이벤트 발행 신뢰성 baseline 문서 정합 (runtime-flows / project-structure / operations 갱신)
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Documentation
|
||||
- Priority: Medium
|
||||
- Owner: project maintainers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-091: 이벤트 발행 재시도 + 강제 재연결 (idle 후 첫 발행 무유실)
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Exception
|
||||
- Priority: High
|
||||
- Owner: project maintainers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-092: 발행 전용 연결 idle keepalive (서버 측 heartbeat 사망 예방)
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Exception
|
||||
- Priority: High
|
||||
- Owner: project maintainers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-093: DLQ 가시성/경보 + emit 실패 경보 (trex.events.dlq 관측성)
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Exception
|
||||
- Priority: High
|
||||
- Owner: project maintainers
|
||||
|
||||
+5
-4
@@ -171,13 +171,14 @@
|
||||
| REQ-088 | 운영 조회 CLI — 보유·오늘 체결·실현 PnL·대기 주문 | Interface | Implemented | High | project maintainers | [req-088](./Interface/req-088_operator-pnl-query-cli.md) |
|
||||
| REQ-089 | 운영자 런북 — docs/operations.md 신설 | Documentation | Implemented | Medium | project maintainers | [req-089](./Documentation/req-089_operator-runbook.md) |
|
||||
| REQ-090 | 관측성 baseline 문서 정합 — 로깅/관측성·PnL 조회 흐름 | Documentation | Implemented | Medium | project maintainers | [req-090](./Documentation/req-090_observability-baseline-alignment.md) |
|
||||
| REQ-091 | 이벤트 발행 재시도 + 강제 재연결 (idle 후 첫 발행 무유실) | Exception | Approved | High | project maintainers | [req-091](./Exception/req-091_publish-retry-and-force-reconnect.md) |
|
||||
| REQ-092 | 발행 전용 연결 idle keepalive (서버 측 heartbeat 사망 예방) | Exception | Approved | High | project maintainers | [req-092](./Exception/req-092_publish-idle-keepalive.md) |
|
||||
| REQ-093 | DLQ 가시성/경보 + emit 실패 경보 (trex.events.dlq 관측성) | Exception | Approved | High | project maintainers | [req-093](./Exception/req-093_dlq-visibility-and-emit-error-alert.md) |
|
||||
| REQ-094 | 이벤트 발행 신뢰성 baseline 문서 정합 (runtime-flows / project-structure / operations 갱신) | Documentation | Approved | Medium | project maintainers | [req-094](./Documentation/req-094_event-publisher-reliability-baseline-alignment.md) |
|
||||
| REQ-091 | 이벤트 발행 재시도 + 강제 재연결 (idle 후 첫 발행 무유실) | Exception | Implemented | High | project maintainers | [req-091](./Exception/req-091_publish-retry-and-force-reconnect.md) |
|
||||
| REQ-092 | 발행 전용 연결 idle keepalive (서버 측 heartbeat 사망 예방) | Exception | Implemented | High | project maintainers | [req-092](./Exception/req-092_publish-idle-keepalive.md) |
|
||||
| REQ-093 | DLQ 가시성/경보 + emit 실패 경보 (trex.events.dlq 관측성) | Exception | Implemented | High | project maintainers | [req-093](./Exception/req-093_dlq-visibility-and-emit-error-alert.md) |
|
||||
| REQ-094 | 이벤트 발행 신뢰성 baseline 문서 정합 (runtime-flows / project-structure / operations 갱신) | Documentation | Implemented | Medium | project maintainers | [req-094](./Documentation/req-094_event-publisher-reliability-baseline-alignment.md) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-06-19 :: REQ-091·092·093·094 Approved → **Implemented** (confirm-req-implemented, BAT-047 release-candidate, dcy-031/OPS-005): 발행 재시도+강제재연결(091)·idle keepalive(092, execution-loop 단일스레드 틱)·DLQ 가시성+emit ERROR 경보(093, 구조화 로그 dcy-029 재사용)·baseline 정합(094) 구현·검증 완료. 독립 opus QC=PASS-WITH-NOTES→시정 2건(ruff F401·AC-3 프로덕션 경로) 완료, 팀장 재확인 **554 passed/47 skipped/회귀 0/ruff clean**. NFR-1 wire 무회귀(interface-contract diff 0). 수용 한계: half-open/broker재시작/idle=mock unit만(실 broker 통합 skip→운영/CI 위임), NFR-2 ≤5초는 재연결+재시도 구간만 엄밀(1차 socket_timeout 5s 최악 초과 가능). OQ-1/3 design Answered. 다음=draft-release(배포=trex-core 재배포→사장님 결재, tsl 라이브 선행).
|
||||
- 2026-06-19 :: REQ-091·092·093·094 Proposed → **Approved** (confirm-req, 팀장): 필수 필드·구체 AC 충족, source dcy-031(confirmed, Open OQ 0) 정합, blocker 없음. 팀장 판정 — FR-4의 REQ-093 흡수 동의, OQ-1/OQ-3 design 인계 확정, NFR-2 타임아웃 수치는 draft-batch-design 확정(비차단). 다음=suggest-batch-reqs + draft-batch(REQ-091~094 단일 batch 편성 예정, 고위험 opus QC).
|
||||
- 2026-06-19 :: REQ-091·092·093·094 신규 생성(Proposed, draft-req dcy-031): 이벤트 발행 연결 신뢰성 — REQ-091 발행 재시도+강제 재연결(FR-1/B, S-1·S-2, NFR-1~3 흡수), REQ-092 idle keepalive(FR-2/A, OQ-1 design 인계), REQ-093 DLQ 가시성+emit 경보(FR-3/D + FR-4, OQ-3 design 인계, S-3), REQ-094 baseline 문서 정합(runtime-flows/project-structure/operations, docs전용). NFR-2·NFR-3은 REQ-091~093 AC에 흡수. 고위험(M1 라이브) batch — opus QC 강제(CONFIRM①).
|
||||
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
"""BAT-047 — Event publisher connection reliability tests (REQ-091/092/093).
|
||||
|
||||
All tests run without a live RabbitMQ broker using unittest.mock. The
|
||||
RabbitMQTransport is exercised through its public interface; pika internals
|
||||
are replaced by lightweight mocks so the retry / keepalive / DLQ logic can
|
||||
be unit-tested deterministically.
|
||||
|
||||
AC coverage:
|
||||
REQ-091 AC-1 : half-open → reconnect + retry → success (no error raised)
|
||||
REQ-091 AC-2 : half-open + retry also fails → TransportUnavailableError
|
||||
REQ-091 AC-3 : timeout budget — _force_reconnect uses connect_timeout=2.0
|
||||
REQ-091 AC-4 : normal publish (conn alive) — no retry overhead
|
||||
REQ-091 AC-5 : consume/RPC paths untouched by this change (interface check)
|
||||
REQ-091 AC-6 : broker restart scenario — reconnect + retry succeeds
|
||||
REQ-092 AC-1 : keepalive_tick calls process_data_events on live conn
|
||||
REQ-092 AC-2 : keepalive_tick executes only on owning object (no lock escape)
|
||||
REQ-092 AC-3 : simultaneous publish + keepalive — no crash (single-thread)
|
||||
REQ-092 AC-4 : keepalive non-blocking (time_limit=0 passed to pika)
|
||||
REQ-092 AC-5 : consume/RPC paths unaffected (keepalive not called there)
|
||||
REQ-093 AC-1 : unroutable → _publish_dlq called + ERROR log emitted
|
||||
REQ-093 AC-3 : emit ERROR on final publish failure (daemon layer)
|
||||
REQ-093 AC-4 : _publish_dlq itself fails → ERROR log, no silent loss
|
||||
REQ-093 AC-6 : consume/RPC paths unaffected (DLQ change local to publish)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pika.exceptions
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport
|
||||
from trex_core.application.messaging import TransportUnavailableError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_transport() -> RabbitMQTransport:
|
||||
"""Return a RabbitMQTransport instance without connecting to a real broker."""
|
||||
return RabbitMQTransport("amqp://localhost/", timeout=5.0)
|
||||
|
||||
|
||||
def _inject_connected(t: RabbitMQTransport, conn=None, channel=None):
|
||||
"""Put the transport into CONNECTED state with mock pika objects."""
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
if conn is None:
|
||||
conn = MagicMock()
|
||||
conn.is_open = True
|
||||
if channel is None:
|
||||
channel = MagicMock()
|
||||
channel.is_open = True
|
||||
t._conn = conn
|
||||
t._channel = channel
|
||||
t._health = TransportHealth.CONNECTED
|
||||
return conn, channel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-091 AC-1: half-open → reconnect + retry → success
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPublishRetryOnHalfOpen:
|
||||
"""REQ-091 AC-1: publish succeeds after forced reconnect."""
|
||||
|
||||
def test_half_open_first_attempt_fails_retry_succeeds(self, caplog):
|
||||
"""Simulates a half-open connection: is_open=True but basic_publish raises
|
||||
AMQPConnectionError on first call. The transport must reconnect and retry,
|
||||
and publish_event must NOT raise.
|
||||
"""
|
||||
t = _make_transport()
|
||||
mock_conn, mock_ch = _inject_connected(t)
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_basic_publish(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise pika.exceptions.AMQPConnectionError("connection reset")
|
||||
# Second call (after reconnect) succeeds silently.
|
||||
|
||||
mock_ch.basic_publish.side_effect = fake_basic_publish
|
||||
|
||||
reconnect_called = {"yes": False}
|
||||
|
||||
def fake_force_reconnect():
|
||||
reconnect_called["yes"] = True
|
||||
# After reconnect, the channel is fresh and functional.
|
||||
new_ch = MagicMock()
|
||||
new_ch.is_open = True
|
||||
new_ch.basic_publish.return_value = None # success
|
||||
t._channel = new_ch
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
t._health = TransportHealth.CONNECTED
|
||||
|
||||
with patch.object(t, "_force_reconnect", side_effect=fake_force_reconnect):
|
||||
# Must not raise.
|
||||
t.publish_event("slot_filled_buy.A.005930", {"slot": "s1"})
|
||||
|
||||
assert reconnect_called["yes"], "_force_reconnect must be called on first failure"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-091 AC-2: retry also fails → TransportUnavailableError raised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPublishRetryExhausted:
|
||||
"""REQ-091 AC-2: when reconnect+retry also fails, error is propagated."""
|
||||
|
||||
def test_retry_exhausted_raises_transport_unavailable(self):
|
||||
t = _make_transport()
|
||||
mock_conn, mock_ch = _inject_connected(t)
|
||||
|
||||
mock_ch.basic_publish.side_effect = pika.exceptions.AMQPConnectionError("gone")
|
||||
|
||||
def fake_force_reconnect():
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
new_ch = MagicMock()
|
||||
new_ch.is_open = True
|
||||
new_ch.basic_publish.side_effect = pika.exceptions.AMQPConnectionError(
|
||||
"still gone"
|
||||
)
|
||||
t._channel = new_ch
|
||||
t._health = TransportHealth.CONNECTED
|
||||
|
||||
with patch.object(t, "_force_reconnect", side_effect=fake_force_reconnect):
|
||||
with pytest.raises(TransportUnavailableError) as exc_info:
|
||||
t.publish_event("slot_filled_buy.A.005930", {"slot": "s1"})
|
||||
|
||||
assert "reconnect+retry" in str(exc_info.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-091 AC-3: timeout budget — _force_reconnect uses connect_timeout=2.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestForceReconnectTimeout:
|
||||
"""REQ-091 AC-3 / NFR-2: _force_reconnect passes connect_timeout=2.0."""
|
||||
|
||||
def test_force_reconnect_uses_2s_timeout(self):
|
||||
t = _make_transport()
|
||||
_inject_connected(t)
|
||||
|
||||
connect_calls: list = []
|
||||
|
||||
def fake_connect(connect_timeout=None):
|
||||
connect_calls.append(connect_timeout)
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
new_conn = MagicMock()
|
||||
new_conn.is_open = True
|
||||
new_ch = MagicMock()
|
||||
new_ch.is_open = True
|
||||
t._conn = new_conn
|
||||
t._channel = new_ch
|
||||
t._health = TransportHealth.CONNECTED
|
||||
|
||||
with patch.object(t, "_connect", side_effect=fake_connect):
|
||||
t._force_reconnect()
|
||||
|
||||
assert len(connect_calls) == 1
|
||||
assert connect_calls[0] == 2.0, (
|
||||
f"_force_reconnect must pass connect_timeout=2.0, got {connect_calls[0]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-091 AC-4: normal publish (conn alive) — no retry path triggered
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalPublishNoRetry:
|
||||
"""REQ-091 AC-4: successful first-attempt publish does not invoke retry."""
|
||||
|
||||
def test_normal_publish_does_not_call_force_reconnect(self):
|
||||
t = _make_transport()
|
||||
_inject_connected(t)
|
||||
t._channel.basic_publish.return_value = None # success
|
||||
|
||||
with patch.object(t, "_force_reconnect") as mock_fr:
|
||||
t.publish_event("slot_filled_buy.A.005930", {"ok": True})
|
||||
|
||||
mock_fr.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-091 AC-6: broker restart — force reconnect + retry succeeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBrokerRestartScenario:
|
||||
"""REQ-091 AC-6: after broker restart the transport reconnects and retries."""
|
||||
|
||||
def test_broker_restart_publish_recovers(self):
|
||||
t = _make_transport()
|
||||
_inject_connected(t)
|
||||
|
||||
# Simulate broker restart: first publish raises OSError (TCP reset).
|
||||
t._channel.basic_publish.side_effect = OSError("connection refused")
|
||||
|
||||
recovered = {"yes": False}
|
||||
|
||||
def fake_force_reconnect():
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
new_ch = MagicMock()
|
||||
new_ch.is_open = True
|
||||
new_ch.basic_publish.return_value = None
|
||||
t._channel = new_ch
|
||||
t._health = TransportHealth.CONNECTED
|
||||
recovered["yes"] = True
|
||||
|
||||
with patch.object(t, "_force_reconnect", side_effect=fake_force_reconnect):
|
||||
# publish_event must succeed without raising.
|
||||
t.publish_event("slot_filled_buy.A.005930", {"ev": "restart"})
|
||||
|
||||
assert recovered["yes"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-092 AC-1: keepalive_tick calls process_data_events on live conn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeepaliveTick:
|
||||
"""REQ-092 AC-1: keepalive_tick delegates to conn.process_data_events."""
|
||||
|
||||
def test_keepalive_tick_calls_process_data_events_on_live_connection(self):
|
||||
t = _make_transport()
|
||||
mock_conn, _ = _inject_connected(t)
|
||||
mock_conn.is_open = True
|
||||
|
||||
t.keepalive_tick()
|
||||
|
||||
mock_conn.process_data_events.assert_called_once_with(time_limit=0)
|
||||
|
||||
def test_keepalive_tick_noop_when_disconnected(self):
|
||||
"""REQ-092 AC-1 variant: no-op if connection is None (shutdown path)."""
|
||||
t = _make_transport()
|
||||
# _conn is None by default — keepalive_tick must not raise.
|
||||
t.keepalive_tick() # should return silently
|
||||
|
||||
def test_keepalive_tick_noop_when_conn_closed(self):
|
||||
"""keepalive_tick skips when is_open=False."""
|
||||
t = _make_transport()
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.is_open = False
|
||||
t._conn = mock_conn
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
t._health = TransportHealth.DISCONNECTED
|
||||
|
||||
t.keepalive_tick()
|
||||
|
||||
mock_conn.process_data_events.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-092 AC-4: keepalive non-blocking — time_limit=0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeepaliveTimeLimitZero:
|
||||
"""REQ-092 AC-4: process_data_events is called with time_limit=0."""
|
||||
|
||||
def test_keepalive_uses_time_limit_zero(self):
|
||||
t = _make_transport()
|
||||
mock_conn, _ = _inject_connected(t)
|
||||
mock_conn.is_open = True
|
||||
|
||||
t.keepalive_tick()
|
||||
|
||||
args, kwargs = mock_conn.process_data_events.call_args
|
||||
assert kwargs.get("time_limit", args[0] if args else None) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-092 AC-3: publish + keepalive in same thread — no crash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeepaliveAndPublishSameThread:
|
||||
"""REQ-092 AC-3: calling keepalive then publish in the same thread is safe."""
|
||||
|
||||
def test_keepalive_then_publish_sequential_no_error(self):
|
||||
t = _make_transport()
|
||||
mock_conn, _ = _inject_connected(t)
|
||||
mock_conn.is_open = True
|
||||
t._channel.basic_publish.return_value = None
|
||||
|
||||
# Sequential in same thread — simulates execution-loop tick.
|
||||
t.keepalive_tick()
|
||||
t.publish_event("slot_filled_buy.A.005930", {"seq": 1})
|
||||
|
||||
mock_conn.process_data_events.assert_called_once_with(time_limit=0)
|
||||
t._channel.basic_publish.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-092 AC-5: consume/RPC paths are unaffected by keepalive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeepaliveDoesNotAffectConsumePaths:
|
||||
"""REQ-092 AC-5: keepalive is specific to publish path; consume API unchanged."""
|
||||
|
||||
def test_keepalive_tick_not_present_on_consume_rpc_forever_signature(self):
|
||||
"""The consume_rpc_forever signature is unchanged."""
|
||||
import inspect
|
||||
sig = inspect.signature(RabbitMQTransport.consume_rpc_forever)
|
||||
params = list(sig.parameters)
|
||||
assert "queue" in params
|
||||
assert "handler" in params
|
||||
assert "stop" in params
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-093 AC-1: unroutable → _publish_dlq called + ERROR log emitted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDlqVisibilityOnUnroutable:
|
||||
"""REQ-093 AC-1: unroutable event triggers _publish_dlq + ERROR log."""
|
||||
|
||||
def test_unroutable_calls_publish_dlq_and_logs_error(self, caplog):
|
||||
t = _make_transport()
|
||||
_inject_connected(t)
|
||||
|
||||
t._channel.basic_publish.side_effect = pika.exceptions.UnroutableError([])
|
||||
|
||||
dlq_calls: list = []
|
||||
|
||||
def fake_dlq(rk, payload, *, reason):
|
||||
dlq_calls.append((rk, reason))
|
||||
|
||||
with patch.object(t, "_publish_dlq", side_effect=fake_dlq):
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
# Must not raise — unroutable is handled via DLQ.
|
||||
t.publish_event("slot_filled_buy.A.005930", {"x": 1})
|
||||
|
||||
assert len(dlq_calls) == 1
|
||||
assert dlq_calls[0][1] == "unroutable"
|
||||
|
||||
def test_publish_dlq_logs_error_on_entry(self, caplog):
|
||||
"""REQ-093 AC-1: _publish_dlq itself logs ERROR when DLQ entry is created."""
|
||||
t = _make_transport()
|
||||
_inject_connected(t)
|
||||
# Make DLQ publish succeed (no-op).
|
||||
t._channel.basic_publish.return_value = None
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="trex_core.adapters.messaging.rabbitmq"):
|
||||
t._publish_dlq("slot_filled_buy.A.005930", {"x": 1}, reason="unroutable")
|
||||
|
||||
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert any("DLQ" in r.message for r in errors), (
|
||||
"Expected ERROR log with 'DLQ' when _publish_dlq is called"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-093 AC-4: _publish_dlq itself fails → ERROR log, no silent loss
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDlqPublishFailureLogsError:
|
||||
"""REQ-093 AC-4: when DLQ publish itself raises, ERROR is logged."""
|
||||
|
||||
def test_dlq_publish_failure_logs_error_not_warning(self, caplog):
|
||||
t = _make_transport()
|
||||
_inject_connected(t)
|
||||
|
||||
# First basic_publish for DLQ raises (after _ensure_connected succeeds).
|
||||
t._channel.basic_publish.side_effect = Exception("DLQ broker down")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="trex_core.adapters.messaging.rabbitmq"):
|
||||
# _publish_dlq must swallow the exception but log ERROR.
|
||||
t._publish_dlq("slot_filled_buy.A.005930", {"x": 1}, reason="unroutable")
|
||||
|
||||
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(error_records) >= 1
|
||||
# The DLQ publish failure must be ERROR, not WARNING.
|
||||
dlq_fail_records = [r for r in error_records if "DLQ" in r.message]
|
||||
assert dlq_fail_records, "Expected ERROR log mentioning 'DLQ' on publish failure"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-093 AC-3: emit ERROR on final publish failure (daemon layer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEmitErrorOnFinalPublishFailure:
|
||||
"""REQ-093 AC-3: _make_event_emitter logs ERROR (not WARNING) on failure."""
|
||||
|
||||
def test_emit_logs_error_on_transport_unavailable(self, caplog):
|
||||
"""The emitter created by _make_event_emitter must log ERROR when
|
||||
publish_event raises TransportUnavailableError after retry.
|
||||
|
||||
Uses the production Daemon._make_event_emitter() directly (via a
|
||||
lightweight stub instance) so that any change to the production error
|
||||
path is caught as a regression.
|
||||
"""
|
||||
from trex_core.runtime.daemon import Daemon
|
||||
from trex_core.application.messaging import TransportUnavailableError
|
||||
|
||||
# Construct a minimal Daemon stub without triggering __init__ (which
|
||||
# requires DB / config / broker connections). Only event_publisher is
|
||||
# accessed by _make_event_emitter, so that is the only attribute we set.
|
||||
daemon_stub = object.__new__(Daemon)
|
||||
fake_publisher = MagicMock()
|
||||
fake_publisher.publish_event.side_effect = TransportUnavailableError("no broker")
|
||||
daemon_stub.event_publisher = fake_publisher
|
||||
|
||||
# Obtain the emitter from the production code path.
|
||||
emit = daemon_stub._make_event_emitter()
|
||||
|
||||
# Minimal slot stub.
|
||||
slot = MagicMock()
|
||||
slot.strategy_id = "A"
|
||||
slot.symbol = "005930"
|
||||
slot.slot_id = "slot-1"
|
||||
slot.status = MagicMock()
|
||||
slot.status.value = "holding"
|
||||
slot.updated_at = None
|
||||
slot.portfolio_id = "PF-1"
|
||||
slot.entry_price = None
|
||||
slot.qty = 10
|
||||
slot.instance_key = "default"
|
||||
slot.actor = None
|
||||
slot.reason = None
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="trex_core.runtime.daemon"):
|
||||
emit("slot_filled_buy", slot)
|
||||
|
||||
error_records = [
|
||||
r for r in caplog.records
|
||||
if r.levelno >= logging.ERROR and "event publish failed" in r.message
|
||||
]
|
||||
assert error_records, (
|
||||
"Expected ERROR log 'event publish failed' from Daemon._make_event_emitter"
|
||||
)
|
||||
# Must NOT be mere WARNING.
|
||||
warn_only = [
|
||||
r for r in caplog.records
|
||||
if r.levelno == logging.WARNING and "event publish failed" in r.message
|
||||
]
|
||||
assert not warn_only, "publish failure must not be WARNING; must be ERROR"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-093 AC-6: consume/RPC paths unaffected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConsumePaths:
|
||||
"""REQ-093 AC-6 / REQ-091 AC-5: consume/RPC interface signature unchanged."""
|
||||
|
||||
def test_rabbitmq_transport_has_consume_methods(self):
|
||||
t = _make_transport()
|
||||
assert callable(getattr(t, "consume_rpc_forever", None))
|
||||
assert callable(getattr(t, "consume_events_forever", None))
|
||||
assert callable(getattr(t, "call_rpc", None))
|
||||
assert callable(getattr(t, "declare_topology", None))
|
||||
|
||||
def test_keepalive_tick_exists_as_callable(self):
|
||||
t = _make_transport()
|
||||
assert callable(getattr(t, "keepalive_tick", None))
|
||||
@@ -144,6 +144,9 @@ class InMemoryTransport:
|
||||
if entry in self._event_subscribers:
|
||||
self._event_subscribers.remove(entry)
|
||||
|
||||
def keepalive_tick(self) -> None:
|
||||
"""No-op for in-memory transport (REQ-092 — RabbitMQ only)."""
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._available = False
|
||||
|
||||
@@ -76,10 +76,11 @@ class RabbitMQTransport:
|
||||
self._health = TransportHealth.DISCONNECTED
|
||||
|
||||
# ------------------------------------------------------------ lifecycle
|
||||
def _connect(self) -> None:
|
||||
def _connect(self, connect_timeout: Optional[float] = None) -> None:
|
||||
params = pika.URLParameters(self._url)
|
||||
params.socket_timeout = self._timeout
|
||||
params.blocked_connection_timeout = self._timeout
|
||||
t = connect_timeout if connect_timeout is not None else self._timeout
|
||||
params.socket_timeout = t
|
||||
params.blocked_connection_timeout = t
|
||||
params.heartbeat = 30
|
||||
with self._lock:
|
||||
self._health = TransportHealth.RECONNECTING
|
||||
@@ -102,6 +103,36 @@ class RabbitMQTransport:
|
||||
if not ok:
|
||||
self._connect()
|
||||
|
||||
def _force_reconnect(self) -> None:
|
||||
"""Force-close current connection and reconnect (REQ-091).
|
||||
|
||||
Uses a shorter socket_timeout (2 s) so a single publish attempt stays
|
||||
well within the 5 s execution-loop block budget (NFR-2).
|
||||
"""
|
||||
self.close()
|
||||
self._connect(connect_timeout=2.0)
|
||||
|
||||
def keepalive_tick(self) -> None:
|
||||
"""Process pending I/O frames on the publish connection (REQ-092).
|
||||
|
||||
MUST be called from the thread that owns this transport instance
|
||||
(pika BlockingConnection is NOT thread-safe). The execution-loop
|
||||
calls this once per tick (interval < heartbeat/2) so the broker's
|
||||
idle-connection reaper never fires.
|
||||
|
||||
Uses time_limit=0 to be non-blocking — only drains already-pending
|
||||
frames from the socket buffer.
|
||||
"""
|
||||
with self._lock:
|
||||
conn = self._conn
|
||||
if conn is None or not conn.is_open:
|
||||
return
|
||||
try:
|
||||
conn.process_data_events(time_limit=0)
|
||||
except Exception: # noqa: BLE001 — keepalive is best-effort
|
||||
_log.debug("keepalive_tick process_data_events failed", exc_info=True)
|
||||
self._mark_disconnected()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
ch, conn = self._channel, self._conn
|
||||
@@ -165,6 +196,27 @@ class RabbitMQTransport:
|
||||
ch.queue_bind(queue, EVENTS_EXCHANGE, routing_key=binding_pattern)
|
||||
|
||||
# ------------------------------------------------------------ publish
|
||||
def _do_basic_publish(
|
||||
self,
|
||||
routing_key: str,
|
||||
body: bytes,
|
||||
*,
|
||||
mandatory: bool,
|
||||
) -> None:
|
||||
"""Internal: call basic_publish on current channel (must be connected)."""
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
ch.basic_publish(
|
||||
exchange=EVENTS_EXCHANGE,
|
||||
routing_key=routing_key,
|
||||
body=body,
|
||||
properties=pika.BasicProperties(
|
||||
content_type="application/json",
|
||||
delivery_mode=2, # persistent
|
||||
),
|
||||
mandatory=mandatory,
|
||||
)
|
||||
|
||||
def publish_event(
|
||||
self,
|
||||
routing_key: str,
|
||||
@@ -172,31 +224,56 @@ class RabbitMQTransport:
|
||||
*,
|
||||
mandatory: bool = True,
|
||||
) -> None:
|
||||
"""Publish to trex.events exchange with retry-on-failure (REQ-091).
|
||||
|
||||
Strategy:
|
||||
1. Attempt publish (after _ensure_connected).
|
||||
2. On AMQPError/OSError (including half-open stale connections):
|
||||
force-reconnect, then retry once.
|
||||
3. If retry also fails: raise TransportUnavailableError.
|
||||
4. UnroutableError (no subscribers) → DLQ, no retry needed.
|
||||
|
||||
is_open=True is NOT trusted alone — a half-open connection will pass
|
||||
the is_open check but fail on basic_publish. The retry path handles
|
||||
this transparently (REQ-091 FR-1).
|
||||
"""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
try:
|
||||
self._ensure_connected()
|
||||
ch = self._channel
|
||||
assert ch is not None
|
||||
ch.basic_publish(
|
||||
exchange=EVENTS_EXCHANGE,
|
||||
routing_key=routing_key,
|
||||
body=body,
|
||||
properties=pika.BasicProperties(
|
||||
content_type="application/json",
|
||||
delivery_mode=2, # persistent
|
||||
),
|
||||
mandatory=mandatory,
|
||||
)
|
||||
self._do_basic_publish(routing_key, body, mandatory=mandatory)
|
||||
except pika.exceptions.UnroutableError:
|
||||
# No matching queue subscribers → push to DLQ collector (REQ-023 AC-6).
|
||||
self._publish_dlq(routing_key, payload, reason="unroutable")
|
||||
except (pika.exceptions.AMQPError, OSError) as exc:
|
||||
except (pika.exceptions.AMQPError, OSError) as first_exc:
|
||||
# First attempt failed (possibly half-open). Force reconnect + retry once.
|
||||
_log.debug(
|
||||
"publish_event first attempt failed, force-reconnecting (rk=%s): %s",
|
||||
routing_key, first_exc,
|
||||
)
|
||||
self._mark_disconnected()
|
||||
raise TransportUnavailableError(
|
||||
f"publish_event failed: {exc.__class__.__name__}: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
self._force_reconnect()
|
||||
self._do_basic_publish(routing_key, body, mandatory=mandatory)
|
||||
except pika.exceptions.UnroutableError:
|
||||
# Reconnected but still unroutable after retry.
|
||||
self._publish_dlq(routing_key, payload, reason="unroutable")
|
||||
except (pika.exceptions.AMQPError, OSError) as retry_exc:
|
||||
self._mark_disconnected()
|
||||
raise TransportUnavailableError(
|
||||
f"publish_event failed after reconnect+retry: "
|
||||
f"{retry_exc.__class__.__name__}: {retry_exc}"
|
||||
) from retry_exc
|
||||
|
||||
def _publish_dlq(self, routing_key: str, payload: dict, *, reason: str) -> None:
|
||||
"""Best-effort publish to DLQ (REQ-093).
|
||||
|
||||
Logs ERROR on both DLQ entry (visibility) and DLQ publish failure
|
||||
so the event is never silently lost from the operations perspective.
|
||||
"""
|
||||
_log.error(
|
||||
"DLQ entry: reason=%s rk=%s payload_keys=%s",
|
||||
reason, routing_key, list(payload.keys()),
|
||||
)
|
||||
try:
|
||||
self._ensure_connected()
|
||||
ch = self._channel
|
||||
@@ -213,7 +290,7 @@ class RabbitMQTransport:
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - DLQ best-effort
|
||||
_log.warning("DLQ publish failed", exc_info=True)
|
||||
_log.error("DLQ publish failed (rk=%s)", routing_key, exc_info=True)
|
||||
|
||||
# ------------------------------------------------------------ RPC client
|
||||
def call_rpc(
|
||||
|
||||
@@ -112,6 +112,15 @@ class MessagingTransport(Protocol):
|
||||
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)."""
|
||||
...
|
||||
|
||||
@@ -244,12 +244,24 @@ class Daemon:
|
||||
try:
|
||||
self.event_publisher.publish_event(rk, payload)
|
||||
except Exception: # noqa: BLE001 - publish failure must not roll back
|
||||
_log.warning("event publish failed (rk=%s)", rk, exc_info=True)
|
||||
# REQ-093: escalate to ERROR so operators can observe via log
|
||||
# alert. Include routing key + slot identity for correlation.
|
||||
_log.error(
|
||||
"event publish failed (rk=%s slot_id=%s strategy=%s)",
|
||||
rk,
|
||||
getattr(slot, "slot_id", None) or getattr(slot, "id", None),
|
||||
getattr(slot, "strategy_id", None),
|
||||
exc_info=True,
|
||||
)
|
||||
return emit
|
||||
|
||||
# ------------------------------------------------------------ workers
|
||||
def _execution_loop(self) -> None:
|
||||
while not self.stop.wait(self.config.execution_interval_sec):
|
||||
# REQ-092: keepalive tick — process pending I/O frames on the
|
||||
# publish connection before each execution run. Called in the
|
||||
# same thread that owns event_publisher (pika thread-safety rule).
|
||||
self.event_publisher.keepalive_tick()
|
||||
try:
|
||||
self.execution.run_once()
|
||||
except Exception: # noqa: BLE001 - loop must survive transient errors
|
||||
|
||||
Reference in New Issue
Block a user