diff --git a/.gitignore b/.gitignore index eeec414..60394ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ __pycache__/ .pytest_cache/ /.env /.vscode/ +# BAT-013 / REQ-048: per-instance TOML configs hold instance_key + portfolio_hub_token +# (secrets) — never commit. The tracked example below is exempt. +/config/*.toml +!/config/instance.example.toml diff --git a/config/instance.example.toml b/config/instance.example.toml new file mode 100644 index 0000000..c13a172 --- /dev/null +++ b/config/instance.example.toml @@ -0,0 +1,12 @@ +# trex-core per-instance config (BAT-013 / REQ-048). +# Copy to e.g. config/inst-1.toml and pass with: trex-core-daemon --config config/inst-1.toml +# Real per-instance files are gitignored (they hold the portfolio-hub token). + +# Instance identifier. Drives queue names: +# default -> trex.intent.req / trex.snapshot.req / trex.control.req (legacy literal) +# -> trex.intent.req. etc + trex.direct routing key {instance_key}.{channel} +instance_key = "default" + +# portfolio-hub auth token (kept out of .env). Injected into PortfolioHubConfig.token +# when TREX_PORTFOLIO_HUB_BASE_URL is set in the environment. +portfolio_hub_token = "" diff --git a/docs/batches/bat-013_20260607_multi-instance-topology/design.md b/docs/batches/bat-013_20260607_multi-instance-topology/design.md new file mode 100644 index 0000000..e210b6a --- /dev/null +++ b/docs/batches/bat-013_20260607_multi-instance-topology/design.md @@ -0,0 +1,51 @@ +# BAT-013 Design + +- Status: confirmed +- Batch: BAT-013 + +## Architecture Summary +다중 인스턴스가 동일 RabbitMQ 에서 split-brain 없이 운영되도록 토폴로지·설정을 인스턴스화한다. + +1. **동적 큐명**(`topology.py`): `intent_queue/snapshot_queue/control_queue(instance_key)`. `instance_key in (None,"default")` → 기존 리터럴(`trex.intent.req` 등), 그 외 → `{base}.{instance_key}`. 기존 상수는 default 값으로 보존(하위호환). +2. **exchange**(`topology.py`+`rabbitmq.py`): `trex.direct`(topic,durable) + `trex.broadcast`(fanout,durable). `declare_topology(instance_key)` 가 인스턴스 큐를 direct 에 `{instance_key}.{intent|control|snapshot}` 로 바인딩 + control 큐를 broadcast 에 dual 바인딩. +3. **설정**(`config.py`): `DaemonInstanceConfig(instance_key, portfolio_hub_token)` + `from_toml(path)`(tomllib) + `DaemonConfig.from_config(toml_path)` 가 `from_env()` 결과에 TOML 을 병합(`portfolio_hub_token` → PortfolioHubConfig.token). +4. **진입점**(`daemon.main`): argparse `--config` 필수. instance_key 를 declare/consume 에 일관 전달. + +## Changed Areas +- Docs: interface-contract.md(routing key)·runtime-flows.md(TOML 기동/라우팅)·project-structure.md(동적 큐명/설정/exchange) — REQ-051. +- Modules: + - `trex_core/adapters/messaging/topology.py` — 큐명 빌더 + DIRECT/BROADCAST exchange 상수 + `direct_routing_key` + - `trex_core/adapters/messaging/rabbitmq.py` — `declare_topology(instance_key="default")` + exchange 선언 + binding(direct per-instance + control dual) + (필요시) broadcast publish helper + - `trex_core/runtime/config.py` — `DaemonInstanceConfig` + `from_toml` + `from_config` + - `trex_core/runtime/daemon.py` — `--config` argparse(main) + instance_key 보관 + consumer 큐명에 instance_key 적용 + declare_topology(instance_key) + - `.gitignore` — 인스턴스 TOML 패턴 +- Interfaces: + - `intent_queue(instance_key) -> str` 등 (default 하위호환) + - `declare_topology(instance_key="default")` + - `direct_routing_key(instance_key, channel_type) -> "{instance_key}.{channel_type}"` + - `DaemonInstanceConfig.from_toml(path)`, `DaemonConfig.from_config(toml_path)` + +## Key Decisions +1. **default = 리터럴 큐명(특수 처리)**: FR-2(suffix)와 FR-5(default 무회귀)의 긴장을 해소 — named instance 만 suffix, default 는 기존 이름 유지. 기존 messaging 테스트/배포 무변경. (planning R-1) +2. **declare_topology 기본 인자 `instance_key="default"`**: 기존 호출부(`transport.declare_topology()`)가 인자 없이도 default 동작 → 호출부 광범위 수정 회피. +3. **exchange 는 항상 선언(default 포함)**: direct/broadcast 는 default 인스턴스에서도 선언·바인딩하되, 기존 직접 큐 reply-to RPC 경로는 그대로 동작(추가 바인딩은 비파괴적). 기존 RPC 회귀 없음(FR-5/AC-6). +4. **TOML 우선 병합**: `from_config` 는 `from_env()` 로 베이스 구성 후 TOML 의 `portfolio_hub_token` 을 PortfolioHubConfig.token 으로 override. `instance_key` 는 TOML 전용. +5. **`--config` 필수**: argparse required. 누락 시 argparse 가 usage 출력 + exit≠0(OQ-2). 단, `from_env()` 단독 호출(테스트/라이브러리)은 계속 가능. +6. **pika per-thread**: 바인딩 추가만, consumer 스레드/커넥션 수 불변(NFR-2). + +## Edge Cases +- default 큐명 충돌 없음: `*.default` 를 쓰지 않고 리터럴 사용. +- TOML 누락 필드/파일 부재 → 명확한 예외(FR-1/AC-4). +- broadcast 중복 control 전달 → 핸들러 idempotency(REQ-036/advisory lock, R-3). +- 기존 old 큐 잔류 → 자연 소멸(OQ-1), 코드 무관. +- snapshot/intent reply-to RPC: 클라이언트가 direct routing key 로 보내든 직접 큐로 보내든, 인스턴스 큐가 direct 바인딩 + 직접 publish 둘 다 수신 가능(하위호환). + +## Architecture Impact +- runtime-flows: TOML 기동 + directed/broadcast 라우팅 + split-brain 방지 — REQ-051. +- project-structure: topology 동적화 + 설정 계층 + exchange — REQ-051. +- interface-contract: routing key 계약 — REQ-051. + +## Reference Doc Update Plan +- interface-contract.md: routing key(`{instance_key}.{channel_type}`)+broadcast+큐명 규칙 (REQ-051 AC-1) +- runtime-flows.md: `--config` 기동 + directed/broadcast + split-brain (REQ-051 AC-2) +- project-structure.md: 동적 큐명/DaemonInstanceConfig/`--config`/exchange (REQ-051 AC-3) diff --git a/docs/batches/bat-013_20260607_multi-instance-topology/implementation.md b/docs/batches/bat-013_20260607_multi-instance-topology/implementation.md new file mode 100644 index 0000000..a4c1b1e --- /dev/null +++ b/docs/batches/bat-013_20260607_multi-instance-topology/implementation.md @@ -0,0 +1,45 @@ +# BAT-013 Implementation + +- Status: completed +- Batch: BAT-013 + +## Plan Summary +- 순서: REQ-049 동적 큐명/declare_topology → REQ-050 exchange/binding/routing → REQ-048 TOML 로더/--config → REQ-051 baseline. 각 단계 단위 green 후 진행. default 하위호환 회귀 우선. + +## Changed Files +- (구현 시 기록) + +### 신규 (예정) +- `tests/runtime/test_instance_config.py` (REQ-048) +- `tests/adapters/messaging/test_instance_topology.py` (REQ-049/050) +- 인스턴스 TOML 예시(`config/instance.example.toml`, 추적) (REQ-048) + +### 수정 (예정) +- `trex_core/adapters/messaging/topology.py` (동적 큐명 + DIRECT/BROADCAST + routing key) +- `trex_core/adapters/messaging/rabbitmq.py` (declare_topology(instance_key) + exchange/binding) +- `trex_core/runtime/config.py` (DaemonInstanceConfig + from_toml + from_config) +- `trex_core/runtime/daemon.py` (--config argparse + instance_key 큐 적용) +- `.gitignore` (인스턴스 TOML) +- baseline 3문서 (REQ-051) + +## Changed Files (실제) +- 신규: `config/instance.example.toml`(추적), `tests/runtime/test_instance_config.py`, `tests/adapters/messaging/test_instance_topology.py`. +- 수정: `topology.py`(동적 큐명 빌더 + DEFAULT_INSTANCE_KEY/DIRECT_EXCHANGE/BROADCAST_EXCHANGE + direct_routing_key), `rabbitmq.py`(declare_topology(instance_key) + exchange/binding/control dual + import 정리), `config.py`(DaemonInstanceConfig+from_toml+from_config, DaemonConfig.instance_key), `daemon.py`(--config argparse main + per-instance 큐 구독 + declare_topology(instance_key)), `.gitignore`(인스턴스 TOML), baseline 3문서(REQ-051). + +## Execution Log +1. REQ-049 동적 큐명(default 리터럴) + declare_topology(instance_key) + daemon per-instance consume → 빌더 단위 + messaging 회귀 green. +2. REQ-050 trex.direct/trex.broadcast exchange + binding + routing key → 인프라 게이트(directed 격리 + broadcast dual) green. +3. REQ-048 DaemonInstanceConfig.from_toml + from_config 병합 + --config argparse + .gitignore + example TOML → 로더 단위 + `--config` 누락 subprocess 종료 green. +4. REQ-051 baseline 3문서 정합. +5. 전체 회귀: 단위 271 passed / 30 skipped, ruff clean. +6. 인프라 게이트(PG+RabbitMQ): 301 passed (directed 라우팅 격리 S-2 + broadcast dual S-3 + default 무회귀 S-4 포함). + +## Validation +- 단위: `pytest -q --deselect ...::test_sandbox_token_and_submit` → 271 passed / 30 skipped. +- 인프라 게이트: `TREX_RABBITMQ_TEST_URL=... TREX_POSTGRESQL_TEST_DSN=... pytest -q` → 301 passed. +- lint: `ruff check trex_core tests` → All passed. + +## Remaining Risks +- R-1(declare_topology 변경 회귀): default=리터럴 큐명 하위호환으로 기존 messaging 테스트 무변경 — 301 passed 로 확인(FR-5/NFR-1). +- R-2(old 큐 잔류): 운영 자연 소멸(OQ-1), 코드 무관. +- (이월, 비차단) 실 2-인스턴스 동시 기동 manual e2e: directed 격리·broadcast 는 인프라 게이트 통합 테스트로 검증됨. 실 배포 시 다중 인스턴스 운영 시점 확인. diff --git a/docs/batches/bat-013_20260607_multi-instance-topology/index.md b/docs/batches/bat-013_20260607_multi-instance-topology/index.md new file mode 100644 index 0000000..78b3e8b --- /dev/null +++ b/docs/batches/bat-013_20260607_multi-instance-topology/index.md @@ -0,0 +1,33 @@ +# BAT-013: 다중 인스턴스 RabbitMQ 토폴로지 — TOML config + instance_key 큐 + direct/broadcast exchange + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-009 +- Included REQ: REQ-048, REQ-049, REQ-050, REQ-051 + +## Summary +- dcy-009(다중 인스턴스 RabbitMQ 토폴로지)를 단일 standard batch 로 delivery 한다. per-instance TOML 설정 로더 + `--config` 필수(REQ-048), `instance_key` 기반 동적 큐명 + default 하위호환(REQ-049), `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + 바인딩(REQ-050), baseline 3문서 정합(REQ-051). 다중 인스턴스를 동일 RabbitMQ 에 붙여도 인스턴스 전용 큐로 split-brain(WatchdogStore/SafetySellService 경쟁 소비)을 구조적으로 방지한다. + +## Scope +- In Scope: + - `DaemonInstanceConfig.from_toml` + `--config` 필수 진입점 + env 병합 + `.gitignore` TOML (REQ-048) + - `topology.py` 동적 큐명 빌더(`intent_queue/snapshot_queue/control_queue(instance_key)`, default=리터럴 하위호환) + `declare_topology(instance_key)` + daemon per-instance consume (REQ-049) + - `trex.direct`(topic) + `trex.broadcast`(fanout) exchange + per-instance/control dual binding + routing key 빌더 (REQ-050) + - baseline 3문서(interface-contract/runtime-flows/project-structure) (REQ-051) +- Out of Scope: + - strategy 모듈(orb-v1) publish 코드 변경 — 계약만 정의 + - WatchdogStore Redis 외부화 / leader election / failover + - 전체 env 의 TOML 일원화(KIS/DSN/RABBITMQ_URL 은 `.env` 유지) + - old 큐 자동 삭제 스크립트(운영 절차) + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- Source discovery 단일(dcy-009). OQ-1(old 큐 자연 소멸)·OQ-2(`--config` 필수) Answered. +- 하위호환 핵심(REQ-049): `instance_key="default"` → 리터럴 큐명 → 기존 테스트/배포 무회귀(FR-5/NFR-1). +- NFR-2(pika per-thread) 유지, NFR-3(split-brain) 인스턴스 전용 큐로 달성, NFR-4(민감값) TOML+gitignore. +- multi-instance 라우팅(directed/broadcast)·split-brain 격리는 인프라 게이트(RabbitMQ) 통합/e2e 로 검증. diff --git a/docs/batches/bat-013_20260607_multi-instance-topology/planning.md b/docs/batches/bat-013_20260607_multi-instance-topology/planning.md new file mode 100644 index 0000000..ef02df5 --- /dev/null +++ b/docs/batches/bat-013_20260607_multi-instance-topology/planning.md @@ -0,0 +1,48 @@ +# BAT-013 Planning + +- Status: confirmed +- Batch: BAT-013 +- Profile: standard + +## Included REQ +- REQ-048 (Configuration): `DaemonInstanceConfig.from_toml` (tomllib) + `--config` 필수 진입점 + `from_config` env 병합(`portfolio_hub_token` 주입) + `.gitignore` TOML + 토큰 로그 미노출. +- REQ-049 (Interface): `topology.py` 동적 큐명 빌더(default=리터럴 하위호환) + `declare_topology(instance_key)` + daemon intent/snapshot/control consumer per-instance 큐 구독. +- REQ-050 (Integration): `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + per-instance direct binding + control dual binding + `direct_routing_key` 빌더. +- REQ-051 (Documentation): interface-contract(routing key)/runtime-flows(TOML 기동·라우팅)/project-structure(동적 큐명·설정 계층·exchange) 정합. + +## Delivery Plan +1. REQ-049: `topology.py` 큐명 빌더(default 하위호환) → `rabbitmq.declare_topology(instance_key)` → daemon consumer per-instance. 큐명 단위 + 기존 회귀. +2. REQ-050: `trex.direct`/`trex.broadcast` exchange 선언 + binding(direct per-instance, control dual) + routing key 빌더. 토폴로지 단위 + 인프라 게이트(라우팅 격리/broadcast). +3. REQ-048: `DaemonInstanceConfig.from_toml` + `--config` argparse 진입점 + `from_config` 병합 + `.gitignore` + 토큰 미노출. 로더 단위. +4. REQ-051: baseline 3문서 정합. +5. 전체 회귀 + ruff + 인프라 게이트(default 무회귀 + 라우팅) + (가능 시) 2-인스턴스 e2e. + +## Dependencies +- Internal: + - dcy-006/BAT-007 messaging(topology/rabbitmq/daemon consumer) — 변경 대상 기반. + - BAT-012 control 채널(`trex.control.req`) — control 큐가 broadcast dual binding 대상. + - BAT-008 PortfolioHubConfig.token — TOML 주입 지점. +- External: + - RabbitMQ(topic/fanout exchange). stdlib `tomllib`(py3.12). + +## Design Gate +- Design Required Before Implementation: yes +- Reason: (a) `declare_topology` signature 변경 + 큐명 동적화는 **모든 messaging consumer/test 표면**에 영향 — default 하위호환 규칙(리터럴 vs suffix)을 설계에서 고정해야 회귀를 막는다. (b) topic/fanout exchange + per-instance/dual binding 의 정확한 binding 매트릭스와 routing key 계약을 설계에서 확정해야 strategy 계약과 일치한다. (c) TOML+env 병합 우선순위(`portfolio_hub_token` TOML 우선) + `--config` 진입점 구조를 설계에서 명시. → design.md 동일 턴 scaffold. + +## Milestones +1. M1 — REQ-049 동적 큐명 + declare_topology(instance_key) + default 회귀 green +2. M2 — REQ-050 exchange + binding + routing key + 인프라 게이트(라우팅) green +3. M3 — REQ-048 TOML 로더 + --config + 병합 + .gitignore 단위 green +4. M4 — REQ-051 baseline 3문서 +5. M5 — 전체 회귀 + ruff + 인프라 게이트 + 2-인스턴스 e2e(가능 시) + +## Risks +- R-1 (Medium): `declare_topology`/큐명 변경이 기존 messaging 테스트 다수에 영향. 대응: default=리터럴 큐명 하위호환으로 기존 테스트 무변경 유지(REQ-049 핵심). 빌더에 default 특수 처리. +- R-2 (Medium): old durable 큐 잔류(dcy-009 R-2). 대응: 자연 소멸 허용(OQ-1), 배포 절차 외. 코드 무관. +- R-3 (Low): broadcast 중복 제어(dcy-009 R-3). 대응: kill-switch/pause idempotency(REQ-036/advisory lock). +- R-4 (Low): TOML 토큰 커밋 노출(dcy-009 R-4). 대응: `.gitignore` + 로그 미노출(REQ-048). +- R-5 (Low): pika BlockingConnection thread-safety. 대응: per-thread connection 유지(NFR-2), 바인딩만 추가. + +## Notes +- 하위호환 우선: default 인스턴스는 기존 리터럴 큐명·RPC 경로 그대로 → 기존 277+ 테스트 무회귀가 1차 게이트. +- 2-인스턴스 e2e(라우팅 격리/broadcast)는 RabbitMQ 인프라 게이트 또는 manual; 단일 인스턴스 기본 검증은 단위로 충분. diff --git a/docs/batches/bat-013_20260607_multi-instance-topology/verification.md b/docs/batches/bat-013_20260607_multi-instance-topology/verification.md new file mode 100644 index 0000000..c306cf0 --- /dev/null +++ b/docs/batches/bat-013_20260607_multi-instance-topology/verification.md @@ -0,0 +1,64 @@ +# BAT-013 Verification + +- Status: verified +- Batch: BAT-013 + +## Acceptance Mapping +> 구현 완료 후 각 AC 에 evidence 매핑. 아래는 계획 골격. + +### REQ-048 (TOML 로더 + --config) +| AC | Evidence (예정) | +|----|---------| +| AC-1 from_toml 파싱 | 로더 단위 | +| AC-2 --config 누락 종료 | 진입점 단위/검사 | +| AC-3 token 주입 | 로더 단위(S-5) | +| AC-4 필수 필드 누락 오류 | 로더 단위 | +| AC-5 .gitignore TOML | gitignore 확인 | +| AC-6 token 로그 미노출 | 코드/로그 검사 | + +### REQ-049 (동적 큐명 + 하위호환) +| AC | Evidence (예정) | +|----|---------| +| AC-1 named suffix | 큐명 빌더 단위 | +| AC-2 default 리터럴 | 동일 | +| AC-3 declare named 큐 | 인프라 게이트 | +| AC-4 default 회귀 없음 | 전체 pytest | +| AC-5 per-thread/스레드 수 | daemon 검사 | +| AC-6 인스턴스 격리 | 인프라 게이트/e2e | + +### REQ-050 (exchange + binding) +| AC | Evidence (예정) | +|----|---------| +| AC-1 direct/broadcast 선언 | 인프라 게이트 | +| AC-2 direct binding | 인프라 게이트 | +| AC-3 routing key 빌더 | 단위 | +| AC-4 directed 격리 | 인프라 게이트(2큐) | +| AC-5 broadcast dual 전달 | 인프라 게이트(2큐) | +| AC-6 default 회귀 | 전체 pytest | + +### REQ-051 (baseline 정합) +| AC | Evidence (예정) | +|----|---------| +| AC-1 interface-contract routing key | 문서 diff | +| AC-2 runtime-flows 기동/라우팅 | 문서 diff | +| AC-3 project-structure 동적큐/설정/exchange | 문서 diff | + +## Evidence +- Test: + - REQ-048 로더 6 단위(from_toml/필수 누락/token 주입 S-5/default ik/`--config` 누락 subprocess 종료/gitignore) green. + - REQ-049/050 토폴로지 3 단위(builders default vs named/routing key) + 인프라 게이트 2(directed 격리 S-2 / broadcast dual S-3) green. + - 단위 전체 회귀: `271 passed / 30 skipped`(`::test_sandbox_token_and_submit` deselect). + - 인프라 게이트(PG trex_core_test + RabbitMQ): `301 passed`(directed 라우팅 격리 + broadcast dual + default 무회귀 S-4 + 마이그레이션 round-trip 포함). + - `ruff check trex_core tests` → All passed. +- Logs: `271 passed, 30 skipped` / `301 passed ... ~15s`. + +## Carried-Over / e2e +- S-1(named 큐 선언)·S-2(directed 격리)·S-3(broadcast dual): RabbitMQ 인프라 게이트 통합 테스트로 확인. +- S-4(default 무회귀): 전체 pytest 301 passed. +- S-5(TOML token 주입): 로더 단위. +- (이월, 비차단) 실 2-인스턴스 동시 기동 manual e2e: 실 다중 인스턴스 운영 시점 확인. + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. 제어 큐 rename(BAT-012 R-1)·old 큐 자연 소멸(OQ-1)·strategy publish 변경(Out of Scope)·2-인스턴스 manual e2e 는 비차단(인프라 게이트 통합으로 핵심 라우팅 검증됨). diff --git a/docs/batches/index.md b/docs/batches/index.md index 0b84116..2daeb64 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -20,3 +20,4 @@ | BAT-010 | standard | release-candidate | REQ-034, REQ-035, REQ-036, REQ-041 | dcy-007 | [bat-010](./bat-010_20260607_safety-sell-foundation/) | | BAT-011 | standard | release-candidate | REQ-037, REQ-038, REQ-039, REQ-040, REQ-042 | dcy-007 | [bat-011](./bat-011_20260607_safety-sell-triggers/) | | BAT-012 | standard | release-candidate | REQ-043, REQ-044, REQ-045, REQ-046, REQ-047 | dcy-008 | [bat-012](./bat-012_20260607_kill-switch-control/) | +| BAT-013 | standard | release-candidate | REQ-048, REQ-049, REQ-050, REQ-051 | dcy-009 | [bat-013](./bat-013_20260607_multi-instance-topology/) | diff --git a/docs/discovery/dcy-009_20260607_multi-instance-rabbitmq-topology.md b/docs/discovery/dcy-009_20260607_multi-instance-rabbitmq-topology.md index d15e78e..c7be70c 100644 --- a/docs/discovery/dcy-009_20260607_multi-instance-rabbitmq-topology.md +++ b/docs/discovery/dcy-009_20260607_multi-instance-rabbitmq-topology.md @@ -1,13 +1,13 @@ # 0. 문서 상태 -- 상태: draft +- 상태: confirmed - 문서 ID: dcy-009_20260607_multi-instance-rabbitmq-topology - 이슈명: 인스턴스별 독립 RabbitMQ 채널 토폴로지 — trex-core 다중 인스턴스 운영을 위한 Topic Exchange + Fanout Exchange per-instance 큐 분리 - 작성 시각(KST): 2026-06-07 -- 마지막 갱신 시각(KST): 2026-06-07 (instance_id → instance_key, TOML config 설계 반영) +- 마지막 갱신 시각(KST): 2026-06-07 (draft-req: REQ-048~051 생성) - 대체됨: 없음 - 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: 없음 +- 생성된 REQ 참조: REQ-048, REQ-049, REQ-050, REQ-051 # 1. 계획 상태 요약 @@ -45,7 +45,7 @@ - trex-core 데몬 운영자: `--config {path}.toml` CLI 인자로 인스턴스별 설정 파일 지정 + 큐 모니터링 - 제약사항 - pika `BlockingConnection`은 thread-safe하지 않다 — 현재 consumer 스레드별 독립 Connection 패턴은 변경 없이 유지해야 한다. - - 기존 durable 큐(`trex.intent.req` 등)가 이미 RabbitMQ 서버에 선언되어 있을 수 있다 → 새 큐명으로 전환 시 old 큐 처리 절차가 필요하다(OQ-1). + - 기존 durable 큐(`trex.intent.req` 등)가 이미 RabbitMQ 서버에 선언되어 있을 수 있다 → 새 큐명으로 전환 후 자연 소멸을 허용하며, RabbitMQ admin console에서 적절한 시점에 수동 삭제한다(OQ-1 Answered). - strategy 모듈(orb-v1)이 routing key 변경에 맞게 publish 코드를 업데이트해야 한다 — 인터페이스 계약 정의가 선행되어야 한다. - `portfolio_hub_token`과 `instance_key`는 `.env`에 넣지 않는다 — 기존 `config.py`의 `from_env()` 패턴 단독으로는 이 원칙을 지킬 수 없으므로 TOML 로더가 필수다. @@ -77,14 +77,14 @@ - `docs/interface-contract.md`: routing key 계약 갱신 — `{instance_key}.{channel_type}` 규칙 추가 - 운영 영향: - 인스턴스별 TOML 파일 생성 및 관리 필요(`instance_key`, `portfolio_hub_token` 포함) - - 기존 `trex.intent.req` 등 old 큐가 RabbitMQ에 잔류할 경우 배포 절차에 old 큐 정리 단계 필요(OQ-1) + - 기존 `trex.intent.req` 등 old 큐는 새 큐 전환 후 자연 소멸을 허용한다. RabbitMQ admin console에서 적절한 시점에 수동 삭제(OQ-1 Answered). - `TREX_PORTFOLIO_HUB_TOKEN` env var는 TOML로 이관 후 `.env`에서 제거 필요 # 5. 요구사항 목록 ## 기능 요구사항 -- FR-1: 데몬 기동 시 `--config .toml` CLI 인자를 지원한다. TOML 파일에서 최소 `instance_key`와 `portfolio_hub_token`을 읽는다. 이 두 값은 `.env`에서 읽지 않는다. +- FR-1: 데몬 기동 시 `--config .toml` CLI 인자는 필수다. 인자 없이 기동하면 오류 메시지를 출력하고 즉시 종료한다. TOML 파일에서 최소 `instance_key`와 `portfolio_hub_token`을 읽는다. 이 두 값은 `.env`에서 읽지 않는다. - FR-2: `instance_key` 값을 기반으로 모든 수신 큐명을 `{base}.{instance_key}` 형식으로 suffix화한다. - FR-3: `trex.direct`(type: topic, durable) exchange를 선언하고, 각 인스턴스의 intent/control/snapshot 큐를 routing key `{instance_key}.intent`, `{instance_key}.control`, `{instance_key}.snapshot`으로 바인딩한다. - FR-4: `trex.broadcast`(type: fanout, durable) exchange를 선언하고, 각 인스턴스의 control 큐를 이 exchange에 추가 바인딩(dual binding)한다. 전체 인스턴스 대상 제어 명령은 이 exchange에 publish한다. @@ -124,7 +124,7 @@ - R-1 (Medium): strategy 모듈이 routing key를 변경하지 않으면 다중 인스턴스 환경에서 메시지가 올바른 인스턴스에 도달하지 않는다. - 완화 방안: 인터페이스 계약에 routing key 규칙을 명시한다. `instance_key = "default"` 단일 인스턴스 시에는 routing key `default.intent` 등으로 publish하면 하위 호환 유지 가능. - R-2 (Medium): 기존 durable 큐(`trex.intent.req` 등)가 RabbitMQ에 잔류하면 새 큐로 전환 후 old 큐에 consumer가 없어진다. - - 완화 방안: 배포 절차에 old 큐 삭제 단계를 명시한다(OQ-1). old 큐에 새로 publish되는 메시지는 없으므로 즉시 삭제해도 메시지 손실 없음. + - 완화 방안: 자연 소멸 허용. 배포 후 old 큐에 새로 publish되는 메시지가 없으므로 메시지 누적 없음. 적절한 시점에 RabbitMQ admin console에서 수동 삭제(OQ-1 Answered). - R-3 (Low): `trex.broadcast` exchange 메시지가 모든 인스턴스에 동시 전달되므로 인스턴스별로 동일 제어 명령을 중복 처리한다. - 완화 방안: kill-switch/pause 핸들러에 idempotency 보장(REQ-036 패턴, DB advisory lock). 중복 처리는 무해하게 동작. - R-4 (Low): TOML 설정 파일이 실수로 git에 커밋되면 `portfolio_hub_token` 등 민감값이 노출된다. @@ -136,18 +136,18 @@ - A-2: strategy 모듈(orb-v1)은 인터페이스 계약 변경에 맞춰 routing key를 업데이트할 수 있다. - A-3: 인스턴스별 담당 portfolio 매핑은 TOML 설정 또는 strategy 모듈의 routing 설정으로 관리한다. trex-core 내부에 레지스트리 DB를 두지 않는다. - A-4: `portfolio_hub_token`과 `instance_key`를 `.env`에서 TOML로 이관하면 기존 `.env` 기반 배포 스크립트를 수정해야 한다. KIS 자격증명 등 나머지 env var는 이번 범위에서 이관하지 않는다. -- A-5: TOML 파일 경로는 `--config` CLI 인자로 지정한다. 경로 지정 없이 기동하는 경우 동작에 대한 처리 방침은 OQ-2에서 결정한다. +- A-5: TOML 파일 경로는 `--config` CLI 인자로 지정한다. `--config` 인자는 필수이며, 지정하지 않으면 데몬이 오류 메시지를 출력하고 즉시 종료한다(OQ-2 Answered). 기본값 fallback은 제공하지 않는다. ## 오픈 질문 - OQ-1: 기존 durable 큐(`trex.intent.req` 등) 처리 방식 — 자연 소멸(수동 삭제) vs 배포 절차에 명시적 삭제 포함? - - 상태: Open - - 처리 방안: REQ 단계에서 배포 절차로 결정. old 큐에 publisher가 없어지면 메시지 누적은 없으나 RabbitMQ admin console에 불필요한 큐가 잔류한다. - - 종료 조건: REQ 초안 작성 시 배포 절차 섹션에 old 큐 처리 방식 명시. + - 상태: Answered + - 결정: 자연 소멸 허용. 배포 후 old 큐에 새로 publish되는 메시지는 없으므로 메시지 누적 없음. RabbitMQ admin console에서 적절한 시점에 수동 삭제. 배포 절차에 명시적 삭제 단계를 추가하지 않는다. + - 반영 위치: R-2 완화 방안 - OQ-2: `--config` 인자 없이 데몬 기동 시 동작 — 오류 종료(필수) vs `instance_key = "default"` 기본값으로 기동? - - 상태: Open - - 처리 방안: REQ 단계에서 결정. 다중 인스턴스 안전성을 위해 TOML 필수(오류 종료)를 권장하나, 기존 배포와의 호환성을 위해 기본값 허용도 고려 가능. - - 종료 조건: confirm-discovery 완료 전 Justin Ko 결정. + - 상태: Answered + - 결정: `--config` 필수. 인자 없이 기동 시 오류 메시지 출력 후 즉시 종료. 기본값 fallback 없음. 인스턴스 식별이 보장되지 않은 상태로 기동하는 것은 다중 인스턴스 환경에서 split-brain 위험을 내포하므로 명시적 실패가 안전하다. + - 반영 위치: A-5, FR-1 ### 오픈 질문 처리 기준 @@ -172,16 +172,16 @@ # 8. REQ로 넘기기 전 확인 체크 -- [ ] 범위(In Scope/Out of Scope) 확정 - - 기준: OQ-1(old 큐 처리), OQ-2(TOML 필수 여부) Answered -- [ ] 성공 지표의 측정 방식/데이터 출처 확정 - - 기준: S-1~S-5 각각 테스트 또는 e2e 근거 확정 -- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 - - 기준: R-1(routing key contract), R-2(old 큐), R-4(TOML git 노출) 완화 방안 REQ 단계 인계 여부 확인 -- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - - 기준: OQ-1, OQ-2 결정 완료 -- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정 - - 기준: Justin Ko 확인 +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: OQ-1(old 큐 처리) Answered, OQ-2(TOML 필수) Answered +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-5 각각 테스트 또는 e2e 근거 확정 — S-1~S-5 측정 방식 및 데이터 출처 섹션 7에 전부 기입됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1~R-4 전부 Medium/Low — High 영향 리스크 없음. 모든 리스크에 완화 방안 기입됨. REQ 단계 인계 확정. +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1, OQ-2 모두 Answered +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: Justin Ko 확인 — Section 11 Confirmed By: Justin Ko # 9. 파일 처리 결과 @@ -198,20 +198,15 @@ # 10. 사용자 결정 필요 항목 요약 -## DECIDE - -- {{DECIDE_OLD_QUEUE_MIGRATION}}: 기존 durable 큐(`trex.intent.req` 등) 처리 방식 — 자연 소멸(수동 삭제) vs 배포 절차에 명시적 삭제 포함 (OQ-1) -- {{DECIDE_TOML_REQUIRED}}: `--config` 인자 없이 기동 시 동작 — 오류 종료(TOML 필수) vs `instance_key = "default"` 기본값으로 기동 허용 (OQ-2) - ## CONFIRM -- {{CONFIRM_STRATEGY_MODULE_OUT_OF_SCOPE}}: strategy 모듈(orb-v1)의 routing key 변경은 이번 Discovery 범위 밖(trex-core는 계약 정의만, 구현은 strategy 모듈 담당)인지 확인 -- {{CONFIRM_ENV_RESIDUAL}}: 나머지 env var(KIS 자격증명, PostgreSQL DSN 등)는 이번 범위에서 TOML로 이관하지 않고 `.env`에 유지하는 것으로 확정하는지 확인 +- CONFIRM_STRATEGY_MODULE_OUT_OF_SCOPE: 확인됨. strategy 모듈(orb-v1)의 routing key 구현 변경은 이번 Discovery 범위 밖. trex-core는 exchange·바인딩 계약만 정의하며, strategy 모듈의 publish 코드 변경은 별도 Discovery 또는 REQ에서 다룬다(Section 5 Out of Scope 반영). +- CONFIRM_ENV_RESIDUAL: 확인됨. KIS 자격증명(`TREX_KIS_*`), PostgreSQL DSN, TREX_RABBITMQ_URL 등 기존 env var는 이번 범위에서 `.env` 유지. TOML 이관 대상은 `portfolio_hub_token`과 `instance_key`만(Section 5 Out of Scope + A-4 반영). ## DATA -- {{DATA_TOML_SCHEMA_FULL}}: TOML 파일에 포함할 전체 필드 목록(`instance_key`, `portfolio_hub_token` 외 추가 필드 유무) — REQ 단계 스키마 정의에 필요 -- {{DATA_MULTI_INSTANCE_TRIGGER_TIMING}}: 실제 다중 인스턴스 운영이 필요한 시점(배포 계획) — NFR 우선순위 조정에 필요 +- DATA_TOML_SCHEMA_FULL: REQ 단계 확정. 최소 필드: `instance_key` (string), `portfolio_hub_token` (string). 추가 필드는 REQ 단계 스키마 정의에서 확정한다. +- DATA_MULTI_INSTANCE_TRIGGER_TIMING: REQ/배포 단계 계측 항목. Discovery 단계 범위 밖. 실제 다중 인스턴스 운영 계획은 배포 시점 REQ에서 반영한다. # 11. Discovery Freeze @@ -225,8 +220,8 @@ ## Freeze 플래그 -- Handoff Decision: 보류 -- Handoff Rationale: TOML 설정 파일 도입 + instance_key 기반 토폴로지 설계 완료. OQ-1(old 큐 처리), OQ-2(TOML 필수 여부) 미결 — confirm-discovery에서 결정 후 REQ 초안 작성 가능. -- Ready for REQ Drafting: false -- Confirmed By: TBD -- Confirmed At (KST): TBD +- Handoff Decision: REQ Drafting 진행 가능 +- Handoff Rationale: TOML 설정 파일 도입 + instance_key 기반 토폴로지 설계 완료. OQ-1(old 큐 자연 소멸), OQ-2(TOML 필수) 모두 Answered. CONFIRM/DATA 항목은 REQ 단계에서 확정 가능 — confirm-discovery 후 REQ 초안 작성 진행 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-06-07 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index f6cef89..2c802a4 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -14,4 +14,4 @@ | dcy-006_20260605_strategy-module-rabbitmq-transport | 20260605 | trex-core ↔ 전략 모듈 통신 transport — RabbitMQ 도입 (스냅샷 read + intent 제출 + 이벤트) | [Discovery](./dcy-006_20260605_strategy-module-rabbitmq-transport.md) | confirmed | | dcy-007_20260606_core-autonomous-safety-sell | 20260606 | core 자율 안전 매도 — stop-loss·시간 만기 자동 트리거 | [Discovery](./dcy-007_20260606_core-autonomous-safety-sell.md) | confirmed | | dcy-008_20260607_emergency-stop-kill-switch | 20260607 | 비상 정지/강제 청산(kill-switch) — portfolio/전역 슬롯 청산 + 당일 전략 제외 | [Discovery](./dcy-008_20260607_emergency-stop-kill-switch.md) | confirmed | -| dcy-009_20260607_multi-instance-rabbitmq-topology | 20260607 | 인스턴스별 독립 RabbitMQ 채널 토폴로지 — trex-core 다중 인스턴스 운영을 위한 Topic Exchange + Fanout Exchange per-instance 큐 분리 | [Discovery](./dcy-009_20260607_multi-instance-rabbitmq-topology.md) | draft | \ No newline at end of file +| dcy-009_20260607_multi-instance-rabbitmq-topology | 20260607 | 인스턴스별 독립 RabbitMQ 채널 토폴로지 — trex-core 다중 인스턴스 운영을 위한 Topic Exchange + Fanout Exchange per-instance 큐 분리 | [Discovery](./dcy-009_20260607_multi-instance-rabbitmq-topology.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 7454a6b..bc0f26d 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-07 (BAT-012) +- Last Updated (KST): 2026-06-07 (BAT-013) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -189,6 +189,15 @@ resume_kill_switch 요청·응답 (REQ-046): - snapshot masking 소스(REQ-046): `mask = pending | warning | pause | killswitch_today`. kill-switch 당일 제외는 `(portfolio_id, trading_date)` day-scoped — 다음 거래일 자동 복귀. `portfolio_id="*"` sentinel 은 전역(전체 universe) 마스킹. - 하위호환: `trex.pause.req` 는 legacy alias. 운영 배포 시 제어 클라이언트는 `trex.control.req` 로 이전한다(rollout 결정, REQ-043 Notes). +### Multi-instance routing (BAT-013 / REQ-049/050) + +다중 인스턴스 운영 시 큐명과 라우팅을 `instance_key` 로 분리한다(dcy-009). + +- **큐명 규칙**: `{base}.{instance_key}`(예: `trex.intent.req.inst-1`). `instance_key = "default"` 는 기존 리터럴 큐명(`trex.intent.req` 등)을 그대로 사용한다(하위호환, FR-5). +- **`trex.direct`(topic exchange)**: 특정 인스턴스 지정 전달. routing key = `{instance_key}.{channel_type}`(`channel_type ∈ {intent, snapshot, control}`). 각 인스턴스 큐가 자신의 routing key 로 바인딩 → `inst-1.intent` publish 는 `trex.intent.req.inst-1` 에만 도달. +- **`trex.broadcast`(fanout exchange)**: 전역 제어. 각 인스턴스의 **control 큐가 dual 바인딩**되어, broadcast publish 는 모든 인스턴스 control 큐에 동시 전달(global kill-switch 등). 중복 전달은 핸들러 idempotency(REQ-036/advisory lock)로 무해. +- **기동**: 데몬은 `--config .toml`(필수)로 `instance_key`/`portfolio_hub_token` 을 읽는다(`.env` 아님, REQ-048). + ## Interface: `PortfolioHubReadPort` (Protocol, BAT-008) - Type: external-service (HTTP, portfolio-hub) diff --git a/docs/project-structure.md b/docs/project-structure.md index 25aad45..240cc39 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-07 (BAT-012) +- Last Updated (KST): 2026-06-07 (BAT-013) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물) ## Project Summary @@ -23,7 +23,7 @@ ## Primary Runtime - Type: daemon (sync + thread, no HTTP) + library (slot 도메인) - Entry Points: - - `trex-core-daemon` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — migrate+preallocate+reconcile 후 7 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / control RPC consumer / snapshot scheduler / execution / ledger / safety-sell) 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008 + BAT-011 + BAT-012) + - `trex-core-daemon --config .toml` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — `--config` 필수(per-instance TOML, BAT-013). migrate+preallocate+reconcile 후 7 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / control RPC consumer / snapshot scheduler / execution / ledger / safety-sell)를 `instance_key` 큐로 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008 + BAT-011 + BAT-012 + BAT-013) - `trex-core` (console_script → `trex_core.runtime.cli:main`) :: 운영 CLI (`pause` / `resume` / `paused`) (BAT-008) - RabbitMQ `trex.snapshot.req` / `trex.intent.req` / `trex.pause.req` / `trex.events` :: 매수·매도 전략 데몬과의 transport (docs/interface-contract.md) - SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용) @@ -83,12 +83,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 · in_memory.py(stub) · rabbitmq.py(pika 동기) (BAT-007) +│ │ ├── 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) │ │ ├── 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) · in_memory_intent_store.py · migrate.py · sql/(001_…, 002_create_ledger_outbox.sql, 003_create_snapshot_pause.sql, 004_add_price_thresholds.sql, 005_create_killswitch_exclusion.sql) │ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) -│ ├── config.py # DaemonConfig + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig(TREX_WATCHDOG_*/FORCED_CLOSE/STOP_LOSS_POLL/MARKET_CLOSE) (env) -│ ├── daemon.py # Daemon class: setup(migrate/preallocate/reconcile/declare-topology) + 7 worker threads (execution·ledger·intent-rpc·snapshot-rpc·control-rpc·snapshot-scheduler·safety-sell) + WatchdogStore + KillSwitchService + graceful shutdown +│ ├── config.py # DaemonConfig(+instance_key) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013) +│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/preallocate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown │ └── cli.py # trex-core CLI: pause/resume/paused subcommands (BAT-008 / REQ-032) └── tests/ ├── domain/slot/ # test_state / test_service / test_contract @@ -149,3 +149,4 @@ - 2026-06-07 :: BAT-010 — safety-sell 기반 인프라(`application/watchdog.py` WatchdogStore 신설, `Slot` 에 `stop_loss_price`/`take_profit_price` 필드 + `004_add_price_thresholds.sql` additive 마이그레이션, `SlotService.sell()` 자율 매도 내부 경로, `make_snapshot_rpc_handler` 워치독 갱신, `SafetySellConfig` env 5종 파싱) (REQ-034~036, 041). - 2026-06-07 :: BAT-011 — safety-sell 트리거 레이어(`application/safety_sell.py` SafetySellService 신설 — forced_close/stop-loss·take-profit/장 마감, `BrokerPort.get_current_price()` + KIS `inquire-price`(`FHKST01010100`, CHG-04 정정)·paper stub, `SlotService.update_thresholds()` + intent RPC `update_thresholds` 액션, runtime/daemon 을 7 스레드로 확장(`trex-safety-sell`) + WatchdogStore 배선) (REQ-037~040, 042). - 2026-06-07 :: BAT-012 — 비상 정지/kill-switch(`trex.pause.req`→`trex.control.req` 제어 채널 + action dispatch, `application/killswitch.py` KillSwitchService + `adapters/portfolio/killswitch_store.py` + `005_create_killswitch_exclusion.sql` day-scoped 제외, snapshot masking 에 killswitch_today 추가, `make_control_rpc_handler` + kill_switch/resume_kill_switch, daemon control consumer 전환) (REQ-043~047). +- 2026-06-07 :: BAT-013 — 다중 인스턴스 RabbitMQ 토폴로지(`topology.py` 동적 큐명 빌더 `intent/snapshot/control_queue(instance_key)` default 리터럴 하위호환 + `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + `direct_routing_key`, `rabbitmq.declare_topology(instance_key)` per-instance 큐/바인딩+control dual, `config.DaemonInstanceConfig.from_toml`+`DaemonConfig.from_config`+`instance_key` 필드, daemon `--config` 필수 진입점 + instance_key 큐 구독, `.gitignore` 인스턴스 TOML + `config/instance.example.toml`) (REQ-048~051). diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 078a608..45b18bb 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-07 (BAT-012) +- Last Updated (KST): 2026-06-07 (BAT-013) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -31,7 +31,7 @@ ## Flow: `데몬 lifecycle` (BAT-002, BAT-007) -1. `trex-core-daemon` 기동 → `DaemonConfig.from_env()`(TREX_POSTGRESQL_DSN, `TREX_RABBITMQ_URL` 등). +1. `trex-core-daemon --config .toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅 분리. 2. setup: `apply_migrations(dsn)`(멱등) → 거래일 슬롯 `preallocate_daily_slots`(멱등) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. 3. start: 7개 백그라운드 스레드 동시 기동 - `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014) @@ -156,6 +156,16 @@ 6. 수동 해제: `{action:"resume_kill_switch", scope, portfolio_id?}` → `KillSwitchService.resume()` 가 day-scoped row 삭제 → 다음 응답부터 정상 노출. **이미 청산된 슬롯은 비가역**(매도 되돌리지 않음). 7. 독립성: kill-switch 는 자율 안전 매도 worker(dcy-007)와 스레드/상태 공유 없음. 동일 슬롯 동시 sell() 은 REQ-036 idempotency 로 1회만 전이. +## Flow: `multi-instance 라우팅` (BAT-013 / REQ-049/050) + +다중 인스턴스를 동일 RabbitMQ 에 붙여도 split-brain 없이 운영하기 위한 큐/exchange 분리. + +1. 각 인스턴스는 `--config inst-N.toml`(`instance_key`)로 기동 → 수신 큐명이 `{base}.{instance_key}` 로 분리(`default` 는 리터럴 유지, 하위호환). +2. `declare_topology(instance_key)`: `trex.direct`(topic) + `trex.broadcast`(fanout) 선언 + 인스턴스 intent/snapshot/control 큐를 `trex.direct` 에 `{instance_key}.{channel}` 로 바인딩 + control 큐를 `trex.broadcast` 에 dual 바인딩. +3. **directed**: strategy/운영자가 `trex.direct` 에 `inst-1.intent` 로 publish → `trex.intent.req.inst-1` 에만 전달(다른 인스턴스 큐 미수신). 인스턴스별 WatchdogStore/SafetySellService 가 자기 큐만 소비 → split-brain 방지(NFR-3). +4. **broadcast**: `trex.broadcast` 에 publish → 모든 인스턴스 control 큐 동시 전달(global kill-switch 등). 중복 처리는 idempotency 로 무해. +5. 기존 reply-to RPC 직접 큐 경로(default)는 그대로 동작 — 추가 바인딩은 비파괴적(FR-5 무회귀). + ## Flow: `broker degrade 분기` (BAT-007 / REQ-024) 1. transport connection drop → 어댑터가 `_mark_disconnected()` + 백오프(1s→2s→4s→8s→16s→30s cap) 재연결. diff --git a/docs/srs/Configuration/req-048_per-instance-toml-config.md b/docs/srs/Configuration/req-048_per-instance-toml-config.md new file mode 100644 index 0000000..3397876 --- /dev/null +++ b/docs/srs/Configuration/req-048_per-instance-toml-config.md @@ -0,0 +1,148 @@ +# req-048: per-instance TOML 설정 로더 + `--config` 필수 + +- Status: Implemented +- Type: Configuration +- Priority: High +- Owner: project maintainers + +## Intent + +다중 인스턴스 운영을 위해 인스턴스별 TOML 설정 파일을 도입한다. 데몬 기동 시 `--config .toml` 인자를 **필수**로 받고, TOML 에서 `instance_key` 와 `portfolio_hub_token` 을 읽는다. 이 두 값은 `.env` 에 넣지 않는 것을 원칙으로 하며(민감값 분리), 기존 `DaemonConfig.from_env()` 와 병합해 최종 설정을 구성한다(dcy-009 FR-1, NFR-4, OQ-2/A-5). + +## Requirement + +- `config.py` 에 `DaemonInstanceConfig` dataclass(`instance_key: str`, `portfolio_hub_token: str`)와 `from_toml(path)` 로더를 추가한다(stdlib `tomllib` 사용). +- `DaemonConfig` 구성 시 `--config .toml` 의 TOML 값을 `from_env()` 결과와 병합하는 팩토리(예: `from_config(toml_path)`)를 추가한다. `portfolio_hub_token` 은 TOML 값을 사용해 `PortfolioHubConfig.token` 에 주입한다. +- 데몬 기동 진입점은 `--config` 를 **필수 인자**로 받는다. 인자 없이 기동하면 오류 메시지를 출력하고 비정상 종료(exit code ≠ 0)한다. 기본값 fallback 없음(OQ-2 Answered). +- `instance_key` 와 `portfolio_hub_token` 은 `.env`/OS env 에서 읽지 않는다. TOML 미존재/필수 필드 누락 시 명확한 오류로 종료한다. +- `.gitignore` 에 인스턴스 TOML 경로 패턴(예: `*.toml` 또는 `config/*.toml`)을 추가해 설정 파일이 커밋되지 않게 한다. 토큰/instance_key 는 로그에 평문 출력하지 않는다(NFR-4). + +## Acceptance Criteria + +1. Given `inst-1.toml`(`instance_key="inst-1"`, `portfolio_hub_token="tok"`), When `DaemonInstanceConfig.from_toml("inst-1.toml")`, Then `instance_key="inst-1"`, `portfolio_hub_token="tok"` 로 파싱된다. +2. Given `--config` 인자 없이 데몬 기동, When 진입점 실행, Then 오류 메시지 출력 + exit code ≠ 0 으로 종료한다(기본값 fallback 없음). +3. Given TOML 에 `portfolio_hub_token` 이 있고 `.env`/OS env 에 `TREX_PORTFOLIO_HUB_TOKEN` 미설정, When 최종 설정 구성, Then `PortfolioHubConfig.token` 에 TOML 값이 주입된다(S-5). +4. Given TOML 에 필수 필드(`instance_key`) 누락, When `from_toml`/`from_config`, Then 명확한 오류로 실패한다(조용한 기본값 금지). +5. Given 저장소, When `.gitignore` 확인, Then 인스턴스 TOML 경로 패턴이 무시 대상이다. +6. Given 데몬 기동 로그, When 출력 확인, Then `portfolio_hub_token` 평문이 로그에 노출되지 않는다. + +## Impacted Area + +- Docs: + - `docs/project-structure.md` — 설정 파일 계층(TOML + env) (REQ-051 처리) +- Modules: + - `trex_core/runtime/config.py` — `DaemonInstanceConfig` + `from_toml` + `from_config` 병합 + - 데몬 진입점(`trex_core/runtime/daemon.py:main` 또는 `__main__`) — `--config` 필수 파싱(argparse) + - `.gitignore` — 인스턴스 TOML 패턴 +- Tests: + - `tests/runtime/` — TOML 로더 파싱/필수 인자/토큰 주입/누락 오류 + +## Notes + +- dcy-009 OQ-2 Answered: `--config` 필수, fallback 없음(split-brain 위험 회피). A-5. +- NFR-4(민감값 비노출)는 본 REQ 의 gitignore + 로그 미노출 AC 로 흡수. +- DATA_TOML_SCHEMA_FULL(dcy-009 §10): 최소 필드 `instance_key`/`portfolio_hub_token`. 추가 필드는 본 REQ 구현 스키마에서 확장 가능. +- KIS 자격증명/DSN/RABBITMQ_URL 등 나머지 env 는 이번 범위에서 `.env` 유지(CONFIRM_ENV_RESIDUAL). + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-009(confirmed) → REQ-048 초안 생성. + +#### Intent + +- per-instance TOML 로더 + `--config` 필수(FR-1, NFR-4)를 Configuration REQ 로 정규화. + +#### Acceptance Criteria Delta + +- Added: AC-1~6 전체 + +#### Impacted Area Delta + +- Docs: project-structure.md (REQ-051) +- Modules: config.py, daemon 진입점, .gitignore +- Tests: tests/runtime TOML 로더 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 로 승인. + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req). + +#### Intent + +- 승인 게이트 통과: 필수 필드·구체 AC·Impacted Area·Change Log 완비. dcy-009(confirmed) 범위·결정(OQ-2 TOML 필수)과 모순 없음. TOML 추가 필드 스키마는 구현 단계 확장 가능 비핵심. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery 편성. + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-013 release-candidate). + +#### Intent + +- BAT-013 verification 의 AC↔evidence(로더 6 단위: from_toml/필수 누락/token 주입 S-5/default ik/`--config` 누락 subprocess 종료 AC-2/gitignore)로 AC-1~6 충족. NFR-4(민감값) 흡수. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-013 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-013 배포 편성. token .env 제거는 배포 rollout. diff --git a/docs/srs/Documentation/req-051_multi-instance-baseline-alignment.md b/docs/srs/Documentation/req-051_multi-instance-baseline-alignment.md new file mode 100644 index 0000000..a9019db --- /dev/null +++ b/docs/srs/Documentation/req-051_multi-instance-baseline-alignment.md @@ -0,0 +1,144 @@ +# req-051: multi-instance topology baseline 문서 정합 + +- Status: Implemented +- Type: Documentation +- Priority: Medium +- Owner: project maintainers + +## Intent + +dcy-009(다중 인스턴스 RabbitMQ 토폴로지) 구현(REQ-048~050) 완료 후 `docs/runtime-flows.md`, `docs/project-structure.md`, `docs/interface-contract.md` 세 baseline 문서를 multi-instance topology + TOML 기반 기동 기준으로 정합시킨다(dcy-009 FR-6). + +## Requirement + +- `docs/interface-contract.md` 에 반영한다: + - routing key 계약: directed = `{instance_key}.{channel_type}`(`trex.direct` topic), broadcast = `trex.broadcast`(fanout, 전역 control) + - 인스턴스별 큐명 규칙(`{base}.{instance_key}`, default = 리터럴 하위호환) +- `docs/runtime-flows.md` 에 반영한다: + - TOML(`--config`) 기반 기동 절차(instance_key/portfolio_hub_token 로드) + - multi-instance directed vs broadcast 라우팅 흐름, split-brain 방지(인스턴스 전용 큐) 설명 +- `docs/project-structure.md` 에 반영한다: + - `topology.py` 역할 변경(고정 상수 → 동적 큐명/exchange 빌더) + - 설정 계층(TOML + env 병합), `DaemonInstanceConfig`, `--config` 진입점 + - `trex.direct`/`trex.broadcast` exchange + +## Acceptance Criteria + +1. Given `docs/interface-contract.md`, When routing key 규칙을 찾으면, Then directed(`{instance_key}.{channel_type}`)와 broadcast(fanout) 계약 + 인스턴스 큐명 규칙이 명시되어 있다. +2. Given `docs/runtime-flows.md`, When 기동/라우팅 흐름을 찾으면, Then TOML(`--config`) 기동 + directed/broadcast 라우팅 + split-brain 방지가 서술되어 있다. +3. Given `docs/project-structure.md`, When topology/설정 항목을 찾으면, Then 동적 큐명 빌더 + `DaemonInstanceConfig`/`--config` + `trex.direct`/`trex.broadcast` 가 반영되어 있다. + +## Impacted Area + +- Docs: + - `docs/interface-contract.md` + - `docs/runtime-flows.md` + - `docs/project-structure.md` +- Modules: 없음 (문서 전용) +- Tests: 없음 + +## Notes + +- REQ-048~050 구현 완료 후 batch verification 단계에서 최신 구현 상태를 반영해 수행한다(REQ-042/047 선례). +- baseline 갱신이므로 `bootstrap-baseline` 선행 불필요. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-009(confirmed) → REQ-051 초안 생성. + +#### Intent + +- multi-instance 구현 후 세 baseline 문서 정합(FR-6)을 Documentation REQ 로 정규화. + +#### Acceptance Criteria Delta + +- Added: AC-1~3 전체 + +#### Impacted Area Delta + +- Docs: interface-contract.md, runtime-flows.md, project-structure.md + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 로 승인 (REQ-048~050 구현 batch 이후 연계). + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req). + +#### Intent + +- 승인 게이트 통과: 필수 필드·구체 AC·Impacted Area·Change Log 완비. baseline 정합 문서 REQ로 REQ-048~050 구현 후 verification 단계 수행. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery 편성(REQ-048~050 이후 연계). + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-013 release-candidate). + +#### Intent + +- BAT-013 에서 baseline 3문서 정합 완료: interface-contract(multi-instance routing: 큐명 규칙·trex.direct/trex.broadcast·routing key), runtime-flows(`--config` 기동 + multi-instance 라우팅 흐름 + split-brain 방지), project-structure(동적 큐명 빌더·DaemonInstanceConfig/`--config`·exchange). AC-1~3 충족. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-013 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-013 배포 편성. diff --git a/docs/srs/Integration/req-050_direct-broadcast-exchanges-and-binding.md b/docs/srs/Integration/req-050_direct-broadcast-exchanges-and-binding.md new file mode 100644 index 0000000..0df74d5 --- /dev/null +++ b/docs/srs/Integration/req-050_direct-broadcast-exchanges-and-binding.md @@ -0,0 +1,147 @@ +# req-050: trex.direct(topic) + trex.broadcast(fanout) exchange + 바인딩 + +- Status: Implemented +- Type: Integration +- Priority: High +- Owner: project maintainers + +## Intent + +인스턴스 지정 라우팅과 전역 브로드캐스트를 위해 두 exchange 를 도입한다. `trex.direct`(topic)는 strategy/운영자가 특정 인스턴스에만 메시지를 보낼 때 routing key `{instance_key}.{channel_type}` 으로 라우팅하고, `trex.broadcast`(fanout)는 global kill-switch 등 전체 인스턴스 대상 제어를 모든 control 큐에 동시 전달한다(dcy-009 FR-3, FR-4). routing key 계약을 인터페이스로 정의한다. + +## Requirement + +- `topology.py` 에 `DIRECT_EXCHANGE = "trex.direct"`(type topic, durable), `BROADCAST_EXCHANGE = "trex.broadcast"`(type fanout, durable) 상수와 routing key 빌더(`direct_routing_key(instance_key, channel_type)` → `{instance_key}.{channel_type}`)를 추가한다. +- `declare_topology(instance_key)` 가 두 exchange 를 선언하고, 각 인스턴스의 intent/control/snapshot 큐를 `trex.direct` 에 routing key `{instance_key}.intent`/`.control`/`.snapshot` 으로 바인딩한다(FR-3). +- 각 인스턴스의 **control 큐**를 `trex.broadcast`(fanout)에 추가 바인딩(dual binding)한다(FR-4). 전역 제어 메시지는 `trex.broadcast` 로 publish 하면 모든 인스턴스 control 큐에 전달된다. +- `default` 인스턴스 하위호환: default 의 큐(리터럴명, REQ-049)도 동일 규칙으로 `trex.direct`(`default.intent` 등) + control dual binding 된다. 기존 직접 큐 publish 경로(reply-to RPC)는 유지되어 회귀가 없어야 한다(FR-5). +- routing key 계약은 `docs/interface-contract.md` 에 명시한다(REQ-051): directed = `{instance_key}.{channel_type}`, broadcast = fanout(키 무시). + +## Acceptance Criteria + +1. Given `declare_topology("inst-1")`, When 선언, Then `trex.direct`(topic) + `trex.broadcast`(fanout) exchange 가 durable 로 선언된다. +2. Given `declare_topology("inst-1")`, When 바인딩 확인, Then `trex.intent.req.inst-1`/`...control...`/`...snapshot...` 가 `trex.direct` 에 routing key `inst-1.intent`/`inst-1.control`/`inst-1.snapshot` 으로 바인딩된다(FR-3). +3. Given `direct_routing_key("inst-1","control")`, When 호출, Then `"inst-1.control"` 을 반환한다. +4. Given `inst-1`/`inst-2` 두 인스턴스 큐가 선언됨, When `trex.direct` 에 `inst-1.intent` 로 publish, Then `trex.intent.req.inst-1` 에만 전달되고 `...inst-2` 에는 전달되지 않는다(S-2). +5. Given control 큐 dual binding, When `trex.broadcast` 에 publish, Then `trex.control.req.inst-1` 과 `...inst-2` control 큐 모두에 전달된다(FR-4, S-3). +6. Given `instance_key="default"`, When 전체 회귀, Then 기존 RPC(reply-to) 경로 동작 동일·회귀 없음(FR-5). + +## Impacted Area + +- Docs: + - `docs/interface-contract.md` — routing key 계약(`{instance_key}.{channel_type}`, broadcast) (REQ-051 처리) + - `docs/runtime-flows.md` — directed vs broadcast 라우팅 흐름 (REQ-051 처리) +- Modules: + - `trex_core/adapters/messaging/topology.py` — DIRECT/BROADCAST exchange + routing key 빌더 + - `trex_core/adapters/messaging/rabbitmq.py` — exchange 선언 + 큐 바인딩(direct per-instance + control dual) + broadcast publish 경로 +- Tests: + - `tests/adapters/messaging/` — exchange 선언 + 바인딩 + directed 라우팅 격리 + broadcast 전달(인프라 게이트) + +## Notes + +- R-3(broadcast 중복 처리): kill-switch/pause 핸들러는 idempotent(REQ-036 패턴/advisory lock)라 중복 전달 무해. +- strategy 모듈(orb-v1) publish 코드 변경은 Out of Scope — 본 REQ 는 exchange/binding/routing key 계약만 정의(CONFIRM_STRATEGY_MODULE_OUT_OF_SCOPE). +- broadcast publish helper 는 transport 에 추가하거나 기존 publish 경로 재사용 — 구현에서 결정. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-009(confirmed) → REQ-050 초안 생성. + +#### Intent + +- trex.direct/trex.broadcast exchange + 바인딩 + routing key 계약(FR-3/FR-4)을 Integration REQ 로 정규화. + +#### Acceptance Criteria Delta + +- Added: AC-1~6 전체 + +#### Impacted Area Delta + +- Docs: interface-contract.md, runtime-flows.md (REQ-051) +- Modules: topology.py, rabbitmq.py +- Tests: tests/adapters/messaging (인프라 게이트) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 로 승인. + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req). + +#### Intent + +- 승인 게이트 통과: 필수 필드·구체 AC·Impacted Area·Change Log 완비. dcy-009(confirmed) FR-3/FR-4 와 일치. strategy publish 변경은 Out of Scope(계약만 정의) — blocker 아님. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery 편성. + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-013 release-candidate). + +#### Intent + +- BAT-013 verification 의 AC↔evidence(routing key 단위 + 인프라 게이트: trex.direct/trex.broadcast 선언 + directed 라우팅 격리 S-2 + broadcast dual S-3, 301 passed)로 AC-1~6 충족. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-013 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-013 배포 편성. strategy publish 변경은 별도(Out of Scope). diff --git a/docs/srs/Interface/req-049_instance-key-dynamic-queue-naming.md b/docs/srs/Interface/req-049_instance-key-dynamic-queue-naming.md new file mode 100644 index 0000000..256d938 --- /dev/null +++ b/docs/srs/Interface/req-049_instance-key-dynamic-queue-naming.md @@ -0,0 +1,147 @@ +# req-049: instance_key 기반 동적 큐명 + default 하위호환 + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: project maintainers + +## Intent + +다중 인스턴스가 동일 RabbitMQ 에서 경쟁 소비(round-robin)로 split-brain 을 일으키지 않도록, 수신 큐명을 `instance_key` 기반으로 인스턴스별 분리한다. `topology.py` 의 고정 큐명 상수를 동적 함수로 교체하고, `declare_topology(instance_key)` + 각 consumer 가 인스턴스 전용 큐를 구독하게 한다. `instance_key="default"` 는 기존 리터럴 큐명을 그대로 사용해 하위호환을 보장한다(dcy-009 FR-2, FR-5, NFR-1/2/3). + +## Requirement + +- `topology.py` 의 고정 큐명 상수(`SNAPSHOT_REQ_QUEUE` 등)를 동적 빌더 함수로 교체/보강한다: `intent_queue(instance_key)`, `snapshot_queue(instance_key)`, `control_queue(instance_key)`. + - **하위호환 규칙**: `instance_key in (None, "default")` 이면 기존 리터럴 큐명(`trex.intent.req` / `trex.snapshot.req` / `trex.control.req`)을 반환한다. 그 외에는 `{base}.{instance_key}` 를 반환한다(FR-5: default 무회귀). +- `rabbitmq.py` `declare_topology(instance_key=...)` 가 인스턴스 전용 큐를 선언한다. `daemon.py` 의 intent/snapshot/control consumer 가 동일 `instance_key` 의 전용 큐를 `consume_rpc_forever` 로 구독한다. +- `daemon.py` 는 설정(REQ-048 TOML)에서 읽은 `instance_key` 를 큐 선언/구독에 일관되게 전달한다. +- 기존 pika `BlockingConnection` per-thread 패턴을 유지한다 — 큐 분리로 인해 추가 스레드/커넥션을 만들지 않는다(NFR-2). 워커 스레드 수 불변. + +## Acceptance Criteria + +1. Given `instance_key="inst-1"`, When `intent_queue("inst-1")`/`snapshot_queue`/`control_queue`, Then 각각 `trex.intent.req.inst-1`/`trex.snapshot.req.inst-1`/`trex.control.req.inst-1` 를 반환한다. +2. Given `instance_key="default"`(또는 None), When 동일 빌더, Then 기존 리터럴 `trex.intent.req`/`trex.snapshot.req`/`trex.control.req` 를 반환한다(하위호환). +3. Given `declare_topology("inst-1")`, When 선언, Then `*.inst-1` 인스턴스 전용 큐가 선언된다(S-1 일부). +4. Given `instance_key="default"` 데몬, When 전체 pytest 스위트 실행, Then 회귀 없음(FR-5/NFR-1, 기존 큐명 테스트 통과). +5. Given 데몬 기동, When 워커 스레드 확인, Then per-thread connection 패턴 유지 + 스레드 수 불변(NFR-2). +6. Given `instance_key="inst-1"` 와 `inst-2` 동시 운영(전용 큐 분리), When 각자 큐만 소비, Then 한 인스턴스의 snapshot/intent 소비가 다른 인스턴스 큐에 영향을 주지 않는다(NFR-3 split-brain 방지 기반). + +## Impacted Area + +- Docs: + - `docs/runtime-flows.md`, `docs/project-structure.md` — 동적 큐명/토폴로지 (REQ-051 처리) +- Modules: + - `trex_core/adapters/messaging/topology.py` — 동적 큐명 빌더(default 하위호환) + - `trex_core/adapters/messaging/rabbitmq.py` — `declare_topology(instance_key)` + - `trex_core/runtime/daemon.py` — consumer 가 instance_key 전용 큐 구독 +- Tests: + - `tests/adapters/messaging/` — 큐명 빌더(default vs named) + declare/consume 분리 + 기존 회귀 + +## Notes + +- 하위호환 핵심: default = 리터럴 큐명(특수 처리)이라 기존 테스트/배포 무영향. FR-2 의 suffix 규칙은 named instance 에 적용. +- NFR-3(split-brain 방지)는 인스턴스 전용 큐로 구조적으로 달성 — 다중 인스턴스 e2e 는 verification 에서 확인. +- exchange/binding(트래픽 라우팅)은 REQ-050. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-009(confirmed) → REQ-049 초안 생성. + +#### Intent + +- instance_key 동적 큐명 + default 하위호환(FR-2/FR-5)을 Interface REQ 로 정규화. + +#### Acceptance Criteria Delta + +- Added: AC-1~6 전체 + +#### Impacted Area Delta + +- Docs: runtime-flows.md, project-structure.md (REQ-051) +- Modules: topology.py, rabbitmq.py, daemon.py +- Tests: tests/adapters/messaging + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 로 승인. + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req). + +#### Intent + +- 승인 게이트 통과: 필수 필드·구체 AC·Impacted Area·Change Log 완비. dcy-009(confirmed) FR-2/FR-5 와 일치. default 하위호환 규칙 명확. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery 편성. + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-013 release-candidate). + +#### Intent + +- BAT-013 verification 의 AC↔evidence(빌더 단위 named suffix/default 리터럴 + declare_topology(instance_key) + 전체 301 passed 로 default 무회귀)로 AC-1~6 충족. NFR-1/2/3 흡수. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-013 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-013 배포 편성. diff --git a/docs/srs/index.md b/docs/srs/index.md index 48d605e..25bdff2 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-048` +- Current: `REQ-052` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -128,11 +128,15 @@ | REQ-045 | kill-switch 실행 — 범위별 holding 즉시 청산 + 이력 | Interface | Implemented | High | project maintainers | [req-045](./Interface/req-045_kill-switch-execution.md) | | REQ-046 | kill-switch 당일 제외 masking + 자동해제 + resume | Interface | Implemented | High | project maintainers | [req-046](./Interface/req-046_killswitch-day-exclusion-masking-and-resume.md) | | REQ-047 | kill-switch baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-047](./Documentation/req-047_killswitch-baseline-alignment.md) | +| REQ-048 | per-instance TOML 설정 로더 + --config 필수 | Configuration | Implemented | High | project maintainers | [req-048](./Configuration/req-048_per-instance-toml-config.md) | +| REQ-049 | instance_key 기반 동적 큐명 + default 하위호환 | Interface | Implemented | High | project maintainers | [req-049](./Interface/req-049_instance-key-dynamic-queue-naming.md) | +| REQ-050 | trex.direct(topic) + trex.broadcast(fanout) exchange + 바인딩 | Integration | Implemented | High | project maintainers | [req-050](./Integration/req-050_direct-broadcast-exchanges-and-binding.md) | +| REQ-051 | multi-instance topology baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-051](./Documentation/req-051_multi-instance-baseline-alignment.md) | ## Recent Change Log Summary +- 2026-06-07 :: REQ-048~051 Approved → Implemented (confirm-req-implemented): BAT-013 verify 완료(신규 9 단위 + RabbitMQ 라우팅 2, 단위 271 passed, 인프라 게이트 301 passed(directed 격리 S-2 + broadcast dual S-3 + default 무회귀 S-4), ruff clean). per-instance TOML 로더+`--config`, instance_key 동적 큐명(default 리터럴 하위호환), trex.direct/trex.broadcast exchange+바인딩, baseline 3문서 정합. (confirm-req-implemented) +- 2026-06-07 :: REQ-048~051 Proposed → Approved (confirm-req): dcy-009 multi-instance 4개 전원 승인 게이트 통과. dcy-009(confirmed) FR/OQ 와 모순 없음, 필수 필드·구체 AC 완비. strategy publish 변경·TOML 추가필드는 비핵심 후속. (confirm-req) +- 2026-06-07 :: REQ-048~051 생성 (draft-req): dcy-009(다중 인스턴스 RabbitMQ 토폴로지, confirmed) → 4개 REQ 초안. per-instance TOML 로더+`--config` 필수(048), instance_key 동적 큐명+default 하위호환(049), trex.direct/trex.broadcast exchange+바인딩(050), baseline 정합(051). NFR-1/2/3/4 는 048/049 AC 로 흡수. (draft-req) - 2026-06-07 :: REQ-043~047 Approved → Implemented (confirm-req-implemented): BAT-012 verify 완료(신규 26 단위 + killswitch PG 5, 단위 262 passed, 인프라 게이트 290 passed(마이그레이션 005 round-trip), ruff clean, paper 데몬 control RPC e2e PASS). `trex.control.req` dispatch, KillSwitchService(범위별 청산+이력), killswitch_exclusion store/005, day-scoped masking+resume, baseline 3문서 정합. (confirm-req-implemented) -- 2026-06-07 :: REQ-043~047 Proposed → Approved (confirm-req): kill-switch 5개 전원 승인 게이트 통과. dcy-008(confirmed) 범위·결정(OQ-1/2/4 Answered, OQ-3 Deferred)과 모순 없음, 필수 필드·구체 AC·Impacted Area·Change Log 완비. 큐 rename 호환·전역 sentinel·pending_buy 는 비핵심 후속으로 blocker 아님. (confirm-req) - 2026-06-07 :: REQ-043~047 생성 (draft-req): dcy-008(비상 정지/kill-switch, confirmed) → 5개 REQ 초안. 제어 채널 `trex.control.req`+action dispatch(043), day-scoped 제외 store+마이그레이션(044), kill-switch 실행=범위별 holding 순차 sell()+이력(045), 당일 제외 masking+자동복귀+resume_kill_switch(046), baseline 정합(047). NFR-1/2/3 은 045/046 AC 로 흡수, OQ-3(pending_buy) REQ Deferred. (draft-req) -- 2026-06-07 :: REQ-038 CHG-20260607-05 Approved → Implemented (confirm-req-implemented): tr_id 정정(`FHKST01010100`) 구현·재검증 — kis.py 수정, AC-7 테스트 정정, 단위 236 passed/ruff clean, 실 KIS LIVE `get_current_price=329000`(rt_cd=0) 확인. -- 2026-06-07 :: REQ-038 CHG-20260607-04 Implemented → Approved (change-req): 현재가 시세 tr_id 정정 `TTTC8397R`/`VTTC8397R` → `FHKST01010100`. 배포 전 실 KIS LIVE 진단에서 구 tr_id 가 "없는 서비스 코드"(rt_cd=1)로 거부 → LIVE 에서 손절/익절 silent 미발동 확인. 표준 시세 tr 정상(stck_prpr=329000). (change-req) diff --git a/tests/adapters/messaging/test_instance_topology.py b/tests/adapters/messaging/test_instance_topology.py new file mode 100644 index 0000000..08bd3e1 --- /dev/null +++ b/tests/adapters/messaging/test_instance_topology.py @@ -0,0 +1,114 @@ +"""Multi-instance topology tests (REQ-049/050 / BAT-013). + +Builder/routing-key tests run always. declare_topology + directed/broadcast +routing tests are gated on TREX_RABBITMQ_TEST_URL. +""" +from __future__ import annotations + +import os +import time +import uuid + +import pytest + +from trex_core.adapters.messaging import topology +from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport + + +# ---- builders (always run) -------------------------------------------------- + +def test_named_instance_suffix(): + """REQ-049 AC-1.""" + assert topology.intent_queue("inst-1") == "trex.intent.req.inst-1" + assert topology.snapshot_queue("inst-1") == "trex.snapshot.req.inst-1" + assert topology.control_queue("inst-1") == "trex.control.req.inst-1" + + +def test_default_is_literal(): + """REQ-049 AC-2: default keeps legacy literal names (backward-compat).""" + assert topology.intent_queue("default") == "trex.intent.req" + assert topology.snapshot_queue(None) == "trex.snapshot.req" + assert topology.control_queue() == "trex.control.req" + + +def test_direct_routing_key(): + """REQ-050 AC-3.""" + assert topology.direct_routing_key("inst-1", "control") == "inst-1.control" + assert topology.direct_routing_key("inst-2", "intent") == "inst-2.intent" + + +# ---- RabbitMQ topology (gated) ---------------------------------------------- + +@pytest.fixture +def mq_url() -> str: + url = os.environ.get("TREX_RABBITMQ_TEST_URL") + if not url: + pytest.skip("TREX_RABBITMQ_TEST_URL not set; skipping RabbitMQ integration test") + return url + + +def _delete_instance_queues(url, instance_key): + import pika + conn = pika.BlockingConnection(pika.URLParameters(url)) + try: + ch = conn.channel() + for q in (topology.intent_queue(instance_key), topology.snapshot_queue(instance_key), + topology.control_queue(instance_key)): + try: + ch.queue_delete(queue=q) + except Exception: # noqa: BLE001 + pass + finally: + conn.close() + + +def test_directed_routing_isolation(mq_url): + """REQ-050 AC-4 (S-2): inst-1 routing key reaches only inst-1's queue.""" + import pika + k1 = "it1-" + uuid.uuid4().hex[:6] + k2 = "it2-" + uuid.uuid4().hex[:6] + t1 = RabbitMQTransport(mq_url) + t1.declare_topology(k1) + t2 = RabbitMQTransport(mq_url) + t2.declare_topology(k2) + try: + conn = pika.BlockingConnection(pika.URLParameters(mq_url)) + ch = conn.channel() + ch.basic_publish(exchange=topology.DIRECT_EXCHANGE, + routing_key=topology.direct_routing_key(k1, "intent"), body=b"x") + time.sleep(0.4) + m1 = ch.basic_get(topology.intent_queue(k1), auto_ack=True) + m2 = ch.basic_get(topology.intent_queue(k2), auto_ack=True) + conn.close() + assert m1[0] is not None, "inst-1 intent queue should receive the directed message" + assert m2[0] is None, "inst-2 intent queue must NOT receive it" + finally: + t1.close() + t2.close() + _delete_instance_queues(mq_url, k1) + _delete_instance_queues(mq_url, k2) + + +def test_broadcast_dual_binding(mq_url): + """REQ-050 AC-5 (S-3): broadcast reaches every instance's control queue.""" + import pika + k1 = "bc1-" + uuid.uuid4().hex[:6] + k2 = "bc2-" + uuid.uuid4().hex[:6] + t1 = RabbitMQTransport(mq_url) + t1.declare_topology(k1) + t2 = RabbitMQTransport(mq_url) + t2.declare_topology(k2) + try: + conn = pika.BlockingConnection(pika.URLParameters(mq_url)) + ch = conn.channel() + ch.basic_publish(exchange=topology.BROADCAST_EXCHANGE, routing_key="", body=b"halt") + time.sleep(0.4) + m1 = ch.basic_get(topology.control_queue(k1), auto_ack=True) + m2 = ch.basic_get(topology.control_queue(k2), auto_ack=True) + conn.close() + assert m1[0] is not None and m2[0] is not None, "both control queues should receive broadcast" + finally: + t1.close() + t2.close() + _delete_instance_queues(mq_url, k1) + _delete_instance_queues(mq_url, k2) diff --git a/tests/runtime/test_instance_config.py b/tests/runtime/test_instance_config.py new file mode 100644 index 0000000..fee850e --- /dev/null +++ b/tests/runtime/test_instance_config.py @@ -0,0 +1,68 @@ +"""Per-instance TOML config loader tests (REQ-048 / BAT-013).""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from trex_core.runtime.config import DaemonConfig, DaemonInstanceConfig + +_REPO = Path(__file__).resolve().parents[2] + + +def _write_toml(tmp_path, body: str) -> str: + p = tmp_path / "inst.toml" + p.write_text(body) + return str(p) + + +def test_from_toml_parses(tmp_path): + """AC-1: from_toml reads instance_key + portfolio_hub_token.""" + path = _write_toml(tmp_path, 'instance_key = "inst-1"\nportfolio_hub_token = "tok"\n') + cfg = DaemonInstanceConfig.from_toml(path) + assert cfg.instance_key == "inst-1" + assert cfg.portfolio_hub_token == "tok" + + +def test_from_toml_missing_instance_key_raises(tmp_path): + """AC-4: missing required instance_key → error (no silent default).""" + path = _write_toml(tmp_path, 'portfolio_hub_token = "tok"\n') + with pytest.raises(RuntimeError): + DaemonInstanceConfig.from_toml(path) + + +def test_from_config_injects_token(tmp_path, monkeypatch): + """AC-3 / S-5: TOML token is injected into PortfolioHubConfig when base_url is set, + even with TREX_PORTFOLIO_HUB_TOKEN absent from the environment.""" + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") + monkeypatch.setenv("TREX_PORTFOLIO_HUB_BASE_URL", "http://hub:18000") + monkeypatch.delenv("TREX_PORTFOLIO_HUB_TOKEN", raising=False) + path = _write_toml(tmp_path, 'instance_key = "inst-9"\nportfolio_hub_token = "toml-tok"\n') + cfg = DaemonConfig.from_config(path) + assert cfg.instance_key == "inst-9" + assert cfg.portfolio_hub is not None + assert cfg.portfolio_hub.token == "toml-tok" + + +def test_default_instance_key_when_from_env(monkeypatch): + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") + assert DaemonConfig.from_env().instance_key == "default" + + +def test_config_required_cli_exits_nonzero(): + """AC-2: daemon entry without --config exits non-zero (argparse required).""" + proc = subprocess.run( + [sys.executable, "-m", "trex_core.runtime.daemon"], + capture_output=True, text=True, timeout=30, cwd=str(_REPO), + ) + assert proc.returncode != 0 + assert "config" in (proc.stderr + proc.stdout).lower() + + +def test_gitignore_excludes_instance_toml(): + """AC-5: .gitignore excludes instance TOML but keeps the example.""" + gi = (_REPO / ".gitignore").read_text() + assert "/config/*.toml" in gi + assert "!/config/instance.example.toml" in gi diff --git a/trex_core/adapters/messaging/rabbitmq.py b/trex_core/adapters/messaging/rabbitmq.py index 588ac96..54d200d 100644 --- a/trex_core/adapters/messaging/rabbitmq.py +++ b/trex_core/adapters/messaging/rabbitmq.py @@ -28,14 +28,19 @@ import pika import pika.exceptions from trex_core.adapters.messaging.topology import ( - CONTROL_REQ_QUEUE, + BROADCAST_EXCHANGE, + DEFAULT_INSTANCE_KEY, + DIRECT_EXCHANGE, EVENTS_DLQ_EXCHANGE, EVENTS_DLQ_QUEUE, EVENTS_EXCHANGE, INTENT_REQ_QUEUE, PAUSE_REQ_QUEUE, - SNAPSHOT_REQ_QUEUE, STRATEGY_QUEUE_ARGS, + control_queue, + direct_routing_key, + intent_queue, + snapshot_queue, ) from trex_core.application.messaging import ( EventHandler, @@ -118,37 +123,33 @@ class RabbitMQTransport: return self._health # ------------------------------------------------------------ topology - def declare_topology(self) -> None: + def declare_topology(self, instance_key: str = DEFAULT_INSTANCE_KEY) -> None: self._ensure_connected() ch = self._channel assert ch is not None + dlx = {"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE} # Events topic + DLQ ch.exchange_declare(EVENTS_EXCHANGE, exchange_type="topic", durable=True) ch.exchange_declare(EVENTS_DLQ_EXCHANGE, exchange_type="fanout", durable=True) ch.queue_declare(EVENTS_DLQ_QUEUE, durable=True) ch.queue_bind(EVENTS_DLQ_QUEUE, EVENTS_DLQ_EXCHANGE) - # RPC queues (durable for intent + snapshot) - ch.queue_declare( - INTENT_REQ_QUEUE, - durable=True, - arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE}, - ) - ch.queue_declare( - SNAPSHOT_REQ_QUEUE, - durable=True, - arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE}, - ) - ch.queue_declare( - PAUSE_REQ_QUEUE, - durable=True, - arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE}, - ) - # BAT-012 / REQ-043: operator control channel (action dispatch). - ch.queue_declare( - CONTROL_REQ_QUEUE, - durable=True, - arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE}, - ) + # BAT-013 / REQ-050: multi-instance control exchanges. + ch.exchange_declare(DIRECT_EXCHANGE, exchange_type="topic", durable=True) + ch.exchange_declare(BROADCAST_EXCHANGE, exchange_type="fanout", durable=True) + # Per-instance RPC queues (default → literal names, REQ-049 backward-compat). + iq = intent_queue(instance_key) + sq = snapshot_queue(instance_key) + cq = control_queue(instance_key) + for q, channel_type in ((iq, "intent"), (sq, "snapshot"), (cq, "control")): + ch.queue_declare(q, durable=True, arguments=dlx) + # Directed binding: trex.direct → {instance_key}.{channel_type} + ch.queue_bind(q, DIRECT_EXCHANGE, + routing_key=direct_routing_key(instance_key, channel_type)) + # Control queue is also fanout-bound so global broadcasts hit every instance. + ch.queue_bind(cq, BROADCAST_EXCHANGE) + # Legacy pause queue — declared for the default instance only (REQ-032 alias). + if instance_key == DEFAULT_INSTANCE_KEY: + ch.queue_declare(PAUSE_REQ_QUEUE, durable=True, arguments=dlx) def declare_strategy_queue(self, queue: str, binding_pattern: str) -> None: """Per-strategy durable queue (REQ-023 AC-4) bound to events exchange.""" diff --git a/trex_core/adapters/messaging/topology.py b/trex_core/adapters/messaging/topology.py index 56444f1..3438870 100644 --- a/trex_core/adapters/messaging/topology.py +++ b/trex_core/adapters/messaging/topology.py @@ -19,6 +19,39 @@ PAUSE_REQ_QUEUE = "trex.pause.req" # BAT-008 / REQ-032 (legacy alias of con CONTROL_REQ_QUEUE = "trex.control.req" # BAT-012 / REQ-043 operator control channel # (action: pause|resume|list|kill_switch|resume_kill_switch) +# --- multi-instance topology (BAT-013 / dcy-009) --- +DEFAULT_INSTANCE_KEY = "default" +DIRECT_EXCHANGE = "trex.direct" # topic, durable — directed per-instance routing (REQ-050) +BROADCAST_EXCHANGE = "trex.broadcast" # fanout, durable — global control to all instances (REQ-050) + + +def _instance_queue(base: str, instance_key: str | None) -> str: + """`{base}.{instance_key}` for named instances; literal `base` for default. + + The default special-case keeps existing queue names (and the entire test + suite + deployed clients) byte-for-byte (REQ-049 FR-5 backward-compat). + """ + if instance_key in (None, DEFAULT_INSTANCE_KEY): + return base + return f"{base}.{instance_key}" + + +def intent_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: + return _instance_queue(INTENT_REQ_QUEUE, instance_key) + + +def snapshot_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: + return _instance_queue(SNAPSHOT_REQ_QUEUE, instance_key) + + +def control_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: + return _instance_queue(CONTROL_REQ_QUEUE, instance_key) + + +def direct_routing_key(instance_key: str, channel_type: str) -> str: + """Directed routing key for `trex.direct` (REQ-050): `{instance_key}.{channel_type}`.""" + return f"{instance_key}.{channel_type}" + # --- Events --- EVENTS_EXCHANGE = "trex.events" # topic, durable EVENTS_DLQ_EXCHANGE = "trex.events.dlq" # fanout, durable; receives unroutable/NACKed diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py index 88b444c..fb7101b 100644 --- a/trex_core/runtime/config.py +++ b/trex_core/runtime/config.py @@ -2,7 +2,7 @@ from __future__ import annotations import os -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Optional @@ -85,12 +85,44 @@ class SafetySellConfig: market_close_liquidation_time: Optional[str] = None # "HH:MM" KST, optional +@dataclass(frozen=True) +class DaemonInstanceConfig: + """Per-instance TOML settings (REQ-048 / BAT-013). + + `instance_key` and `portfolio_hub_token` live in a TOML file (not .env) + so they are not committed and so multiple instances can run distinct + queues. Loaded via `--config .toml`. + """ + + instance_key: str + portfolio_hub_token: str = "" + + @staticmethod + def from_toml(path: str) -> "DaemonInstanceConfig": + import tomllib + + with open(path, "rb") as fh: + data = tomllib.load(fh) + instance_key = data.get("instance_key") + if not instance_key: + raise RuntimeError( + f"instance TOML missing required 'instance_key': {path}" + ) + return DaemonInstanceConfig( + instance_key=str(instance_key), + portfolio_hub_token=str(data.get("portfolio_hub_token") or ""), + ) + + @dataclass(frozen=True) class DaemonConfig: dsn: str total_slots: int broker: str # 'paper' | 'kis' execution_interval_sec: float + # BAT-013 / REQ-049: instance identity for multi-instance queue naming. + # "default" keeps legacy literal queue names (backward-compat). + instance_key: str = "default" kis: Optional[KISConfig] = None # REQ-011: paper broker tuning (only consulted when broker == 'paper'). paper: PaperBrokerConfig = PaperBrokerConfig() @@ -179,3 +211,18 @@ class DaemonConfig: snapshot_schedule=snapshot_schedule, safety_sell=safety_sell, ) + + @staticmethod + def from_config(toml_path: str) -> "DaemonConfig": + """Build config from env + a per-instance TOML file (REQ-048 / BAT-013). + + `instance_key` and `portfolio_hub_token` come from TOML (not .env). + TOML `portfolio_hub_token` overrides the (env-derived) PortfolioHubConfig + token when a portfolio-hub base_url is configured. + """ + base = DaemonConfig.from_env() + inst = DaemonInstanceConfig.from_toml(toml_path) + pf = base.portfolio_hub + if inst.portfolio_hub_token and pf is not None: + pf = replace(pf, token=inst.portfolio_hub_token) + return replace(base, instance_key=inst.instance_key, portfolio_hub=pf) diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 3d18d84..1fa2d47 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -217,7 +217,7 @@ class Daemon: transport = _build_transport(self.config) self._transports.append(transport) try: - transport.declare_topology() + transport.declare_topology(self.config.instance_key) except Exception: # noqa: BLE001 pass handler = make_intent_rpc_handler( @@ -225,13 +225,14 @@ class Daemon: slot_service=self.slot_service, watchdog_store=self.watchdog, ) - transport.consume_rpc_forever(topology.INTENT_REQ_QUEUE, handler, self.stop) + transport.consume_rpc_forever( + topology.intent_queue(self.config.instance_key), handler, self.stop) def _snapshot_rpc_loop(self) -> None: transport = _build_transport(self.config) self._transports.append(transport) try: - transport.declare_topology() + transport.declare_topology(self.config.instance_key) except Exception: # noqa: BLE001 pass handler = make_snapshot_rpc_handler( @@ -242,20 +243,22 @@ class Daemon: watchdog_store=self.watchdog, killswitch_store=self.killswitch_store, ) - transport.consume_rpc_forever(topology.SNAPSHOT_REQ_QUEUE, handler, self.stop) + transport.consume_rpc_forever( + topology.snapshot_queue(self.config.instance_key), handler, self.stop) def _control_rpc_loop(self) -> None: # REQ-043: operator control channel (pause/resume/list + kill_switch/resume_kill_switch). transport = _build_transport(self.config) self._transports.append(transport) try: - transport.declare_topology() + transport.declare_topology(self.config.instance_key) except Exception: # noqa: BLE001 pass handler = make_control_rpc_handler( self.pause_store, kill_switch_service=self.kill_switch, ) - transport.consume_rpc_forever(topology.CONTROL_REQ_QUEUE, handler, self.stop) + transport.consume_rpc_forever( + topology.control_queue(self.config.instance_key), handler, self.stop) def _safety_sell_loop(self) -> None: # REQ-037/038/039: forced_close / stop-loss·take-profit / market-close. @@ -281,7 +284,7 @@ class Daemon: ) self.execution.reconcile() # REQ-014 restart recovery try: - self.event_publisher.declare_topology() + self.event_publisher.declare_topology(self.config.instance_key) except Exception: # noqa: BLE001 _log.warning("event publisher topology declare failed", exc_info=True) @@ -325,8 +328,17 @@ class Daemon: def main() -> None: # pragma: no cover - process entrypoint + import argparse + logging.basicConfig(level=logging.INFO) - config = DaemonConfig.from_env() + parser = argparse.ArgumentParser(prog="trex-core-daemon") + parser.add_argument( + "--config", required=True, + help="per-instance TOML config path (instance_key, portfolio_hub_token). " + "Required — no default (REQ-048 / dcy-009 OQ-2).", + ) + args = parser.parse_args() + config = DaemonConfig.from_config(args.config) Daemon(config).run()