rabbit_cluster_acceptance.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import asyncio
  2. import json
  3. import secrets
  4. from datetime import UTC, datetime
  5. from pathlib import Path
  6. from time import monotonic
  7. from uuid import UUID, uuid4
  8. import httpx
  9. import pytest
  10. from sqlalchemy import func, select
  11. from test_after_sales import scenario # noqa: F401
  12. from test_fulfillment import approve, connected, operation_for # noqa: F401
  13. from zhifu.modules.after_sales.models import BusinessEvent
  14. from zhifu.modules.integrations.fulfillment import execute_due
  15. from zhifu.modules.integrations.models import ConsumerReceipt, ExternalOperation, Reservation
  16. from zhifu.modules.integrations.worker import Broker, consume_one, publish_one
  17. from zhifu.oms.models import Exchange
  18. pytestmark = pytest.mark.integration
  19. class LocalCluster:
  20. def __init__(self):
  21. self.identifier = "zhifu-quorum-" + uuid4().hex[:10]
  22. self.names = [self.identifier + "-" + str(index) for index in range(3)]
  23. self.password = secrets.token_hex(24)
  24. self.cookie = secrets.token_hex(24)
  25. self.report = {
  26. "execution_id": self.identifier, "started_at": datetime.now(UTC).isoformat(),
  27. "model_calls": 0, "message_limit": 20, "duration_limit_seconds": 300,
  28. "memory_bytes_per_node": 512 * 1024 * 1024, "cpu_limit_per_node": 1,
  29. "published_messages": 0, "consumed_messages": 0, "management_requests": 0,
  30. "docker_commands": 0, "status": "RUNNING", "phases": [],
  31. }
  32. self.started = monotonic()
  33. async def docker(self, *arguments):
  34. self.report["docker_commands"] += 1
  35. process = await asyncio.create_subprocess_exec(
  36. "docker", *arguments, stdout=asyncio.subprocess.PIPE,
  37. stderr=asyncio.subprocess.PIPE)
  38. try:
  39. async with asyncio.timeout(30):
  40. output, error = await process.communicate()
  41. finally:
  42. if process.returncode is None:
  43. process.kill()
  44. await process.wait()
  45. if process.returncode:
  46. detail = error.decode("utf-8", errors="replace")
  47. detail = detail.replace(self.password, "[redacted]").replace(self.cookie, "[redacted]")
  48. raise RuntimeError(f"Docker {arguments[0]} failed: {detail}")
  49. return output.decode("utf-8").strip()
  50. def phase(self, name):
  51. self.report["phases"].append({"name": name, "elapsed_seconds": round(
  52. monotonic() - self.started, 3)})
  53. async def healthy(self, names):
  54. async with asyncio.timeout(65):
  55. while True:
  56. rows = json.loads(await self.docker("inspect", *names))
  57. if all(row["State"].get("Health", {}).get("Status") == "healthy" for row in rows):
  58. return
  59. if any(row["State"]["Status"] == "exited" for row in rows):
  60. raise RuntimeError("临时 RabbitMQ 节点提前退出")
  61. await asyncio.sleep(2)
  62. async def start(self):
  63. await self.docker("network", "create", "--label", "zhifu.exercise=" + self.identifier,
  64. self.identifier)
  65. for index, name in enumerate(self.names):
  66. # 所有节点和匿名数据卷仅属于本次演练,使用独立网络、端口及受限资源。
  67. await self.docker(
  68. "run", "-d", "--name", name, "--hostname", "rq" + str(index),
  69. "--network", self.identifier, "--label", "zhifu.exercise=" + self.identifier,
  70. "--memory", "512m", "--memory-swap", "512m", "--cpus", "1",
  71. "--pids-limit", "160", "-p", "127.0.0.1::5672", "-p", "127.0.0.1::15672",
  72. "-e", "RABBITMQ_DEFAULT_USER=exercise",
  73. "-e", "RABBITMQ_DEFAULT_PASS=" + self.password,
  74. "-e", "RABBITMQ_ERLANG_COOKIE=" + self.cookie,
  75. "-e", "RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS=+S 2:2 +SDcpu 1 +SDio 1",
  76. "-e", "RABBITMQ_CTL_ERL_ARGS=+S 1:1 +SDcpu 1 +SDio 1",
  77. "--health-cmd", "rabbitmq-diagnostics -q check_running",
  78. "--health-interval", "2s", "--health-timeout", "5s", "--health-retries", "30",
  79. "rabbitmq:4.2-management",
  80. )
  81. await self.healthy(self.names)
  82. for name in self.names[1:]:
  83. await self.docker("exec", name, "rabbitmqctl", "join_cluster", "rabbit@rq0")
  84. await self.healthy(self.names)
  85. rows = await self.refresh_ports()
  86. self.report["image_id"] = rows[0]["Image"]
  87. self.phase("three_nodes_joined")
  88. async def refresh_ports(self):
  89. # Docker 的随机发布端口在容器再次启动后重新读取,不缓存旧的主机端口。
  90. rows = json.loads(await self.docker("inspect", *self.names))
  91. self.ports = {
  92. row["Name"].removeprefix("/"): {
  93. key: int(value[0]["HostPort"])
  94. for key, value in row["NetworkSettings"]["Ports"].items() if value
  95. } for row in rows
  96. }
  97. return rows
  98. def amqp(self, name):
  99. return f"amqp://exercise:{self.password}@127.0.0.1:{self.ports[name]['5672/tcp']}/"
  100. async def query(self, name, path):
  101. self.report["management_requests"] += 1
  102. port = self.ports[name]["15672/tcp"]
  103. async with httpx.AsyncClient(timeout=8, auth=("exercise", self.password)) as client:
  104. response = await client.get(f"http://127.0.0.1:{port}/api/{path}")
  105. response.raise_for_status()
  106. return response.json()
  107. async def cleanup(self):
  108. # 只移除带有本次随机标签的资源,匿名卷随所属容器一起移除。
  109. container_ids = (await self.docker(
  110. "ps", "-aq", "--filter", "label=zhifu.exercise=" + self.identifier)).splitlines()
  111. volumes = set()
  112. if container_ids:
  113. rows = json.loads(await self.docker("inspect", *container_ids))
  114. volumes = {mount["Name"] for row in rows for mount in row["Mounts"]
  115. if mount["Type"] == "volume"}
  116. await self.docker("rm", "-fv", *container_ids)
  117. network_ids = (await self.docker(
  118. "network", "ls", "-q", "--filter", "label=zhifu.exercise=" + self.identifier
  119. )).splitlines()
  120. if network_ids:
  121. await self.docker("network", "rm", *network_ids)
  122. remaining = await self.docker(
  123. "ps", "-aq", "--filter", "label=zhifu.exercise=" + self.identifier)
  124. networks = await self.docker(
  125. "network", "ls", "-q", "--filter", "label=zhifu.exercise=" + self.identifier)
  126. present_volumes = set((await self.docker("volume", "ls", "-q")).splitlines())
  127. assert not remaining and not networks and not (volumes & present_volumes)
  128. self.report["removed_anonymous_volumes"] = len(volumes)
  129. self.report["cleanup_verified"] = True
  130. self.phase("temporary_resources_removed")
  131. async def test_three_node_quorum_leader_failure_and_business_uniqueness(connected): # noqa: F811
  132. cluster = LocalCluster()
  133. report = cluster.report
  134. output = Path(".work/rabbit-cluster-20260922.json")
  135. try:
  136. # 业务演练最多 235 秒,另外保留 55 秒处理所有临时资源清理。
  137. async with asyncio.timeout(235):
  138. await cluster.start()
  139. nodes = await cluster.query(cluster.names[0], "nodes")
  140. assert len(nodes) == 3 and all(node["running"] for node in nodes)
  141. report["nodes_before"] = [node["name"] for node in nodes]
  142. ticket, event_id = await approve(connected)
  143. sessions = connected["app"].state.sessions
  144. queue = "exercise." + uuid4().hex
  145. report["queue"] = queue
  146. report["event_id"] = str(UUID(bytes=event_id))
  147. report["ticket_id"] = ticket["ticket_id"]
  148. async with Broker(cluster.amqp(cluster.names[0]), queue, queue) as broker:
  149. assert await publish_one(sessions, broker, event_id)
  150. report["published_messages"] += 1
  151. async with sessions() as session:
  152. event = await session.get(BusinessEvent, event_id)
  153. assert event.delivery_status == "SENT"
  154. await broker.publish_event(event)
  155. report["published_messages"] += 1
  156. before = await cluster.query(cluster.names[0], "queues/%2F/" + queue)
  157. assert before["type"] == "quorum" and len(before["members"]) == 3
  158. leader = before["leader"]
  159. report["queue_before"] = {key: before[key] for key in ("type", "leader", "members")}
  160. # 根据实际 leader 确定被终止节点,确保验证包含选举。
  161. index = int(leader.removeprefix("rabbit@rq"))
  162. stopped = cluster.names[index]
  163. survivor = next(name for name in cluster.names if name != stopped)
  164. cluster.phase("confirmed_messages_before_failure")
  165. await cluster.docker("kill", stopped)
  166. failure_started = monotonic()
  167. cluster.phase("leader_terminated")
  168. async with asyncio.timeout(30):
  169. while True:
  170. current = await cluster.query(survivor, "queues/%2F/" + queue)
  171. if current.get("leader") and current["leader"] != leader:
  172. break
  173. await asyncio.sleep(1)
  174. report["queue_after_failure"] = {key: current[key] for key in (
  175. "type", "leader", "members")}
  176. async with Broker(cluster.amqp(survivor), queue, queue) as broker:
  177. for _ in range(2):
  178. assert await consume_one(sessions, broker, 5)
  179. report["consumed_messages"] += 1
  180. assert await consume_one(sessions, broker, 5) is False
  181. report["recovery_seconds"] = round(monotonic() - failure_started, 3)
  182. cluster.phase("survivor_consumed_confirmed_messages")
  183. operation = await operation_for(connected, ticket)
  184. assert operation is not None
  185. assert await execute_due(sessions, 5, operation.id)
  186. async with sessions() as session:
  187. for model in (ExternalOperation, Reservation, ConsumerReceipt):
  188. count = await session.scalar(select(func.count()).select_from(model).where(
  189. model.tenant_id == connected["tenant"].bytes))
  190. assert count == 1
  191. async with connected["oms_sessions"]() as session:
  192. assert await session.scalar(select(func.count()).select_from(Exchange).where(
  193. Exchange.tenant_id == connected["tenant"].bytes)) == 1
  194. report["external_operations"] = report["oms_exchanges"] = 1
  195. await cluster.docker("start", stopped)
  196. await cluster.healthy([stopped])
  197. await cluster.refresh_ports()
  198. nodes = await cluster.query(survivor, "nodes")
  199. assert len(nodes) == 3 and all(node["running"] for node in nodes)
  200. report["nodes_recovered"] = [node["name"] for node in nodes]
  201. async with Broker(cluster.amqp(stopped), queue, queue) as broker:
  202. async with sessions() as session:
  203. event = await session.get(BusinessEvent, event_id)
  204. await broker.publish_event(event)
  205. report["published_messages"] += 1
  206. assert await consume_one(sessions, broker, 5)
  207. report["consumed_messages"] += 1
  208. async with sessions() as session:
  209. count = await session.scalar(select(func.count()).select_from(
  210. ExternalOperation).where(
  211. ExternalOperation.tenant_id == connected["tenant"].bytes))
  212. assert count == 1
  213. cluster.phase("original_node_recovered_and_duplicate_consumed")
  214. report["status"] = "PASSED"
  215. except Exception as failure:
  216. report["status"] = "FAILED"
  217. report["failure"] = f"{type(failure).__name__}: {failure}"
  218. raise
  219. finally:
  220. try:
  221. async with asyncio.timeout(55):
  222. await cluster.cleanup()
  223. except Exception as failure:
  224. report["status"] = "FAILED"
  225. report["cleanup_verified"] = False
  226. report["cleanup_failure"] = f"{type(failure).__name__}: {failure}"
  227. raise
  228. finally:
  229. report["elapsed_seconds"] = round(monotonic() - cluster.started, 3)
  230. await asyncio.to_thread(output.parent.mkdir, parents=True, exist_ok=True)
  231. await asyncio.to_thread(output.write_text, json.dumps(
  232. report, ensure_ascii=False, indent=2), encoding="utf-8")