feat: add messaging transport rollout

This commit is contained in:
2026-06-06 00:02:52 +09:00
parent 4b91343ee8
commit 6c7a1cf6f6
383 changed files with 99045 additions and 487 deletions
-112
View File
@@ -1,112 +0,0 @@
"""HTTP /v1 API tests via in-memory adapters (REQ-005/007/008).
No PostgreSQL needed: the app is wired with the in-memory slot repo + intent
store, exercising the full request → admission → transition → response path.
"""
from __future__ import annotations
from datetime import date
import pytest
from fastapi.testclient import TestClient
from trex_core.adapters.api.app import create_app
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import IntentService
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 3)
@pytest.fixture
def ctx():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
svc.preallocate_daily_slots(DAY, 3)
app = create_app(
slot_service=svc, slot_repo=repo, intent_service=intents, default_total_slots=3
)
return repo, svc, intents, TestClient(app)
def _buy(client, intent_id, symbol="005930"):
return client.post(
"/v1/intents/buy",
json={"intent_id": intent_id, "trading_date": DAY.isoformat(), "symbol": symbol, "qty": 10},
)
def test_healthz(ctx):
_, _, _, client = ctx
r = client.get("/v1/healthz")
assert r.status_code == 200 and r.json()["status"] == "ok"
def test_buy_intent_admits_and_transitions(ctx):
repo, _, _, client = ctx
r = _buy(client, "buy-1")
assert r.status_code == 200
body = r.json()
assert body["status"] == "admitted" and body["slot_id"]
slot = repo.fetch_slot(__import__("uuid").UUID(body["slot_id"]))
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
def test_buy_intent_idempotent_resubmit(ctx):
repo, _, _, client = ctx
r1 = _buy(client, "buy-dup")
r2 = _buy(client, "buy-dup") # same intent_id
assert r1.json() == r2.json() # identical decision
# only ONE slot moved out of idle
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1
def test_buy_intent_rejected_when_no_idle(ctx):
_, _, _, client = ctx
for i in range(3):
assert _buy(client, f"buy-fill-{i}").json()["status"] == "admitted"
r = _buy(client, "buy-overflow")
assert r.json()["status"] == "rejected" and r.json()["reason"] == "no_idle_slot"
def test_sell_intent_requires_holding(ctx):
repo, _, _, client = ctx
# a freshly bought slot is pending_buy, not holding -> sell rejected
slot_id = _buy(client, "buy-for-sell").json()["slot_id"]
r = client.post("/v1/intents/sell", json={"intent_id": "sell-1", "slot_id": slot_id})
assert r.json()["status"] == "rejected" and r.json()["reason"].startswith("slot_not_holding")
def test_sell_intent_admits_from_holding(ctx):
repo, svc, _, client = ctx
import uuid
slot_id = uuid.UUID(_buy(client, "buy-h").json()["slot_id"])
svc.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t")
r = client.post("/v1/intents/sell", json={"intent_id": "sell-h", "slot_id": str(slot_id)})
assert r.json()["status"] == "admitted"
assert repo.fetch_slot(slot_id).status is SlotState.PENDING_SELL
def test_get_intent_roundtrip(ctx):
_, _, _, client = ctx
_buy(client, "buy-q")
r = client.get("/v1/intents/buy-q")
assert r.status_code == 200 and r.json()["status"] == "admitted"
assert client.get("/v1/intents/does-not-exist").status_code == 404
def test_list_slots_and_capacity(ctx):
_, _, _, client = ctx
_buy(client, "buy-c")
slots = client.get(
"/v1/slots", params={"status": "pending_buy", "trading_date": DAY.isoformat()}
).json()
assert len(slots) == 1 and slots[0]["status"] == "pending_buy"
cap = client.get(
"/v1/capacity", params={"total_slots": 3, "trading_date": DAY.isoformat()}
).json()
assert cap["idle_count"] == 2 and cap["pending_count"] == 1 and cap["total_slots"] == 3
@@ -0,0 +1,88 @@
"""InMemoryTransport stub tests (REQ-022 AC-1 + REQ-024 degrade)."""
from __future__ import annotations
import threading
import time
import pytest
from trex_core.adapters.messaging.in_memory import InMemoryTransport
from trex_core.adapters.messaging import topology
from trex_core.application.messaging import (
RpcRequest,
RpcResponse,
RpcTimeoutError,
TransportHealth,
TransportUnavailableError,
)
def test_publish_and_subscribe_routes_by_pattern():
t = InMemoryTransport()
received: list[tuple[str, dict]] = []
stop = threading.Event()
th = threading.Thread(
target=t.consume_events_forever,
args=("trex.events.strategy.A", topology.strategy_event_binding_pattern("A"),
lambda rk, p: received.append((rk, p)), stop),
daemon=True,
)
th.start()
time.sleep(0.05)
# Routed to A's subscriber
t.publish_event(topology.event_routing_key("slot_filled_buy", "A", "005930"),
{"slot": "1"})
# NOT routed to A's subscriber (strategy B)
t.publish_event(topology.event_routing_key("slot_filled_buy", "B", "005930"),
{"slot": "2"})
time.sleep(0.05)
stop.set()
th.join(timeout=1.0)
assert len(received) == 1
assert received[0][0] == "slot_filled_buy.A.005930"
assert received[0][1] == {"slot": "1"}
def test_rpc_call_dispatches_to_registered_handler():
t = InMemoryTransport()
def handler(req: RpcRequest) -> RpcResponse:
return RpcResponse(payload={"echo": req.payload, "cid": req.correlation_id})
stop = threading.Event()
th = threading.Thread(
target=t.consume_rpc_forever,
args=("trex.intent.req", handler, stop),
daemon=True,
)
th.start()
time.sleep(0.05)
resp = t.call_rpc("trex.intent.req", {"x": 1}, correlation_id="cid-1", timeout=0.5)
assert resp["echo"] == {"x": 1}
assert resp["cid"] == "cid-1"
stop.set()
th.join(timeout=1.0)
def test_unavailable_transport_raises_on_publish_and_rpc():
t = InMemoryTransport()
t.set_available(False)
assert t.health() is TransportHealth.DISCONNECTED
with pytest.raises(TransportUnavailableError):
t.publish_event("e.A.S", {"x": 1})
with pytest.raises(TransportUnavailableError):
t.call_rpc("trex.intent.req", {"x": 1}, correlation_id="cid", timeout=0.1)
def test_rpc_without_registered_consumer_times_out():
t = InMemoryTransport()
with pytest.raises(RpcTimeoutError):
t.call_rpc("trex.intent.req", {}, correlation_id="cid", timeout=0.05)
def test_publish_event_with_no_subscriber_routes_to_dlq():
t = InMemoryTransport()
t.publish_event("slot_filled_buy.A.005930", {"slot": "1"})
dlq = t.dlq_messages()
assert len(dlq) == 1
assert dlq[0][0] == "slot_filled_buy.A.005930"
@@ -0,0 +1,127 @@
"""End-to-end intent RPC against RabbitMQ + intent_log dedup (REQ-023 AC-1/AC-3).
Skipped without TREX_RABBITMQ_TEST_URL. Wires the real RabbitMQTransport to
the application-layer intent handler and proves that:
- publishing the same correlation_id twice triggers slot transition only ONCE
- the durable RPC queue is declared with the DLQ pointer (AC-3 topology check)
- intent_log persists the dedup decision
"""
from __future__ import annotations
import os
import threading
import time
import uuid
from datetime import date
import pytest
from trex_core.adapters.messaging import topology
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import IntentService
from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 5)
@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")
return url
def _drain(url: str, queue: str) -> None:
import pika
conn = pika.BlockingConnection(pika.URLParameters(url))
try:
ch = conn.channel()
try:
ch.queue_delete(queue=queue)
except Exception: # noqa: BLE001
pass
finally:
conn.close()
def test_intent_admission_rpc_dedups_duplicate_correlation_id_over_rabbitmq(mq_url):
"""REQ-023 AC-1: same correlation_id processed twice ⇒ slot transitions ONCE."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
svc.preallocate_daily_slots(DAY, 3)
handler = make_intent_rpc_handler(intents)
queue = f"trex.test.intent.{uuid.uuid4().hex[:8]}"
server = RabbitMQTransport(mq_url, timeout=5.0)
client = RabbitMQTransport(mq_url, timeout=5.0)
try:
# Pre-declare with DLQ pointer (mirrors production declare_topology shape)
server._ensure_connected()
server._channel.queue_declare(
queue, durable=True,
arguments={"x-dead-letter-exchange": topology.EVENTS_DLQ_EXCHANGE},
)
stop = threading.Event()
th = threading.Thread(
target=server.consume_rpc_forever, args=(queue, handler, stop), daemon=True,
)
th.start()
time.sleep(0.2)
cid = "buy-dup-rmq"
payload = {
"intent_type": "buy", "intent_id": cid,
"trading_date": DAY.isoformat(), "symbol": "005930", "qty": 10,
}
r1 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
r2 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
assert r1 == r2
assert r1["status"] == "admitted"
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1, "intent_log dedup must prevent double slot transition"
stop.set()
th.join(timeout=2.0)
finally:
server.close()
client.close()
_drain(mq_url, queue)
def test_intent_queue_topology_uses_dlq_pointer(mq_url):
"""REQ-023 AC-3 / REQ-022 AC-2 topology assertion."""
import pika
t = RabbitMQTransport(mq_url, timeout=5.0)
try:
t.declare_topology()
# Re-declare with the SAME args is no-op; declaring with DIFFERENT args
# would raise PRECONDITION_FAILED. Use that as a topology assertion.
conn = pika.BlockingConnection(pika.URLParameters(mq_url))
try:
ch = conn.channel()
# Same args → ok
ch.queue_declare(
topology.INTENT_REQ_QUEUE, durable=True,
arguments={"x-dead-letter-exchange": topology.EVENTS_DLQ_EXCHANGE},
)
ch.close()
# Different args → must raise
ch2 = conn.channel()
with pytest.raises(pika.exceptions.ChannelClosedByBroker):
ch2.queue_declare(
topology.INTENT_REQ_QUEUE, durable=False,
)
finally:
try:
conn.close()
except Exception: # noqa: BLE001
pass
finally:
t.close()
@@ -0,0 +1,171 @@
"""RabbitMQTransport integration tests (REQ-022 AC-2, REQ-023, REQ-024).
Skipped without TREX_RABBITMQ_TEST_URL. Each test uses a unique uuid in
queue/routing keys to avoid colliding with other tests or runs.
"""
from __future__ import annotations
import os
import threading
import time
import uuid
import pytest
from trex_core.adapters.messaging import topology
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport
from trex_core.application.messaging import (
RpcRequest,
RpcResponse,
RpcTimeoutError,
TransportHealth,
)
@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 _drain_queue(url: str, queue: str) -> None:
import pika
conn = pika.BlockingConnection(pika.URLParameters(url))
try:
ch = conn.channel()
try:
ch.queue_delete(queue=queue)
except Exception: # noqa: BLE001
pass
finally:
conn.close()
def test_declare_topology_creates_core_entities(mq_url):
t = RabbitMQTransport(mq_url, timeout=5.0)
try:
t.declare_topology()
# If declare_topology succeeded, exchanges/queues now exist. Re-declare
# is idempotent (RabbitMQ no-op on existing matching definitions).
t.declare_topology()
assert t.health() is TransportHealth.CONNECTED
finally:
t.close()
def test_rpc_roundtrip(mq_url):
queue = f"trex.test.rpc.{uuid.uuid4().hex[:8]}"
server = RabbitMQTransport(mq_url, timeout=5.0)
client = RabbitMQTransport(mq_url, timeout=5.0)
try:
# Pre-declare RPC queue (server-side will redeclare too).
ch = server._channel
if server._conn is None:
server._connect()
ch = server._channel
ch.queue_declare(queue, durable=True,
arguments={"x-dead-letter-exchange": topology.EVENTS_DLQ_EXCHANGE})
def handler(req: RpcRequest) -> RpcResponse:
return RpcResponse(payload={"echo": req.payload, "cid": req.correlation_id})
stop = threading.Event()
th = threading.Thread(
target=server.consume_rpc_forever,
args=(queue, handler, stop),
daemon=True,
)
th.start()
time.sleep(0.2)
resp = client.call_rpc(queue, {"hello": "world"}, correlation_id="cid-1", timeout=3.0)
assert resp["echo"] == {"hello": "world"}
assert resp["cid"] == "cid-1"
stop.set()
th.join(timeout=2.0)
finally:
server.close()
client.close()
_drain_queue(mq_url, queue)
def test_event_publish_and_per_strategy_consume(mq_url):
suffix = uuid.uuid4().hex[:8]
strategy_a = f"A-{suffix}"
strategy_b = f"B-{suffix}"
queue_a = topology.strategy_event_queue_name(strategy_a)
queue_b = topology.strategy_event_queue_name(strategy_b)
publisher = RabbitMQTransport(mq_url, timeout=5.0)
consumer_a = RabbitMQTransport(mq_url, timeout=5.0)
consumer_b = RabbitMQTransport(mq_url, timeout=5.0)
received_a: list[tuple[str, dict]] = []
received_b: list[tuple[str, dict]] = []
stop = threading.Event()
try:
publisher.declare_topology()
# Pre-declare per-strategy queues so the publisher's message is not
# discarded before the consumers start.
publisher.declare_strategy_queue(queue_a, topology.strategy_event_binding_pattern(strategy_a))
publisher.declare_strategy_queue(queue_b, topology.strategy_event_binding_pattern(strategy_b))
th_a = threading.Thread(
target=consumer_a.consume_events_forever,
args=(queue_a, topology.strategy_event_binding_pattern(strategy_a),
lambda rk, p: received_a.append((rk, p)), stop),
daemon=True,
)
th_b = threading.Thread(
target=consumer_b.consume_events_forever,
args=(queue_b, topology.strategy_event_binding_pattern(strategy_b),
lambda rk, p: received_b.append((rk, p)), stop),
daemon=True,
)
th_a.start()
th_b.start()
time.sleep(0.3)
rk_a = topology.event_routing_key("slot_filled_buy", strategy_a, "005930")
rk_b = topology.event_routing_key("slot_filled_buy", strategy_b, "005930")
publisher.publish_event(rk_a, {"slot": "1", "for": "A"})
publisher.publish_event(rk_b, {"slot": "2", "for": "B"})
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline and (not received_a or not received_b):
time.sleep(0.05)
assert any(rk == rk_a for rk, _ in received_a)
assert all(rk != rk_b for rk, _ in received_a), "A queue must not receive B events"
assert any(rk == rk_b for rk, _ in received_b)
assert all(rk != rk_a for rk, _ in received_b), "B queue must not receive A events"
finally:
stop.set()
time.sleep(0.2)
publisher.close()
consumer_a.close()
consumer_b.close()
_drain_queue(mq_url, queue_a)
_drain_queue(mq_url, queue_b)
def test_call_rpc_timeout(mq_url):
queue = f"trex.test.rpc.timeout.{uuid.uuid4().hex[:8]}"
client = RabbitMQTransport(mq_url, timeout=5.0)
try:
# Declare a queue with no consumer; RPC must time out.
client._ensure_connected()
client._channel.queue_declare(queue, durable=True)
with pytest.raises(RpcTimeoutError):
client.call_rpc(queue, {"x": 1}, correlation_id="cid-timeout", timeout=0.5)
finally:
client.close()
_drain_queue(mq_url, queue)
def test_close_marks_disconnected(mq_url):
t = RabbitMQTransport(mq_url, timeout=5.0)
t.declare_topology()
assert t.health() is TransportHealth.CONNECTED
t.close()
assert t.health() is TransportHealth.DISCONNECTED
+33
View File
@@ -0,0 +1,33 @@
"""Topology constants + routing key builders (REQ-021)."""
from __future__ import annotations
from trex_core.adapters.messaging import topology
def test_topology_constants():
assert topology.SNAPSHOT_REQ_QUEUE == "trex.snapshot.req"
assert topology.INTENT_REQ_QUEUE == "trex.intent.req"
assert topology.EVENTS_EXCHANGE == "trex.events"
assert topology.EVENTS_DLQ_EXCHANGE == "trex.events.dlq"
def test_event_routing_key_format():
assert topology.event_routing_key("slot_filled_buy", "A", "005930") == \
"slot_filled_buy.A.005930"
# dots/wildcards in inputs are sanitized to underscores
assert topology.event_routing_key("e.x", "y*z", "a#b") == "e_x.y_z.a_b"
# None / empty strategy → "_"
assert topology.event_routing_key("e", None, "S") == "e._.S"
def test_strategy_queue_name_and_binding_pattern():
assert topology.strategy_event_queue_name("A") == "trex.events.strategy.A"
assert topology.strategy_event_binding_pattern("A") == "*.A.#"
def test_strategy_queue_args_have_ttl_and_max_length():
args = topology.STRATEGY_QUEUE_ARGS
assert args["x-message-ttl"] > 0
assert args["x-max-length"] > 0
assert args["x-overflow"] == "drop-head"
assert args["x-dead-letter-exchange"] == topology.EVENTS_DLQ_EXCHANGE