dynamic_scale_acceptance.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import asyncio
  2. import hashlib
  3. import json
  4. import secrets
  5. from collections import Counter
  6. from datetime import timedelta
  7. from ipaddress import ip_network
  8. from pathlib import Path
  9. from time import perf_counter
  10. from uuid import uuid4
  11. import httpx
  12. from capacity_acceptance import accounts, measure, percentiles
  13. from scale_support import ScaleEnvironment
  14. from sqlalchemy import func, select
  15. from sse_capacity_acceptance import wait_released
  16. from test_after_sales import scenario # noqa: F401
  17. from zhifu.modules.after_sales.models import Ticket
  18. from zhifu.modules.agent_runtime.models import ModelAttempt
  19. from zhifu.modules.identity.access_models import AccessSession, Principal, StoreGrant
  20. from zhifu.modules.identity.auth import now
  21. async def test_dynamic_discovery_replacement_and_idempotency(scenario): # noqa: F811
  22. environment = ScaleEnvironment()
  23. report = {"generation_attempts": 0, "maximum_business_requests": 240,
  24. "maximum_monitor_requests": 180, "maximum_seconds": 360, "phases": [],
  25. "status": "INCOMPLETE"}
  26. started = perf_counter()
  27. try:
  28. async with asyncio.timeout(360):
  29. first, _ = await environment.start()
  30. report["database_budget"] = environment.budget
  31. async with httpx.AsyncClient(
  32. base_url=environment.base_url, headers=scenario["headers"]["customer"],
  33. timeout=15, trust_env=False,
  34. ) as client:
  35. key = uuid4().hex
  36. for phase, expected in (("both", 2), ("failed", 1), ("replaced", 2)):
  37. if phase == "failed":
  38. await environment.docker("kill", first)
  39. recovery_started = perf_counter()
  40. elif phase == "replaced":
  41. network = json.loads(await environment.docker(
  42. "network", "inspect", environment.network
  43. ))[0]
  44. address = ip_network(network["IPAM"]["Config"][0]["Subnet"])[10]
  45. replacement = await environment.start_api("api-c", address=address)
  46. assert environment.addresses[replacement] != environment.addresses[first]
  47. recovery_started = perf_counter()
  48. servers = await environment.wait_servers(expected)
  49. reads = await asyncio.gather(*(
  50. client.get(f"/openapi/v1/orders?store_id={scenario['store']}")
  51. for _ in range(64)
  52. ))
  53. writes = await asyncio.gather(*(
  54. client.post("/openapi/v1/after-sales/proposals",
  55. headers={"Idempotency-Key": key}, json=scenario["proposal"])
  56. for _ in range(16)
  57. ))
  58. assert all(row.status_code == 200 for row in reads)
  59. assert all(row.status_code == 201 for row in writes), [r.text for r in writes]
  60. observed = {row.headers["X-Zhifu-Replica"] for row in reads + writes}
  61. assert observed == servers
  62. assert len({row.json()["ticket_id"] for row in writes}) == 1
  63. report["phases"].append({
  64. "phase": phase, "replicas": sorted(servers),
  65. "statuses": dict(Counter(row.status_code for row in reads + writes)),
  66. "read_replicas": dict(Counter(
  67. row.headers["X-Zhifu-Replica"] for row in reads
  68. )),
  69. "recovery_seconds": perf_counter() - recovery_started
  70. if phase != "both" else None,
  71. })
  72. async with scenario["app"].state.sessions() as session:
  73. tickets = await session.scalar(select(func.count()).select_from(Ticket).where(
  74. Ticket.tenant_id == scenario["tenant"].bytes
  75. ))
  76. assert tickets == 1
  77. report.update(status="PASSED", tickets=tickets, addresses=environment.addresses)
  78. finally:
  79. await environment.close()
  80. report.update(seconds=perf_counter() - started, temporary_resources_remaining=0,
  81. docker_commands=environment.commands,
  82. monitor_requests=environment.monitor_requests)
  83. await asyncio.to_thread(Path(".work/dynamic-discovery.json").write_text,
  84. json.dumps(report, indent=2) + "\n", encoding="utf-8"
  85. )
  86. async def sse_accounts(context, count):
  87. sessions = context["app"].state.sessions
  88. identities = []
  89. actors = [uuid4() for _ in range(count)]
  90. async with sessions.begin() as session:
  91. customer = await session.get(Principal, context["accounts"]["customer"].bytes)
  92. session.add_all([Principal(
  93. id=actor.bytes, tenant_id=context["tenant"].bytes, username="scale-" + actor.hex,
  94. password_hash=customer.password_hash, kind="CUSTOMER",
  95. ) for actor in actors])
  96. await session.flush()
  97. for actor in actors:
  98. token = secrets.token_urlsafe(32)
  99. session.add(StoreGrant(
  100. tenant_id=context["tenant"].bytes, principal_id=actor.bytes,
  101. store_id=context["store"].bytes, permission="orders:read",
  102. ))
  103. session.add(AccessSession(
  104. token_hash=hashlib.sha256(token.encode()).digest(),
  105. tenant_id=context["tenant"].bytes, principal_id=actor.bytes,
  106. expires_at=now() + timedelta(minutes=15),
  107. ))
  108. identities.append({"Authorization": "Bearer " + token})
  109. return identities
  110. async def test_three_thousand_sse_connections(scenario): # noqa: F811
  111. environment = ScaleEnvironment()
  112. state = scenario["app"].state.state
  113. assert await state.zcard("sse:global") == 0, "验收开始时需要无其他 SSE 连接"
  114. report = {"connections_target": 3000, "account_count": 1001,
  115. "maximum_sse_requests": 4005, "admission_concurrency": 256,
  116. "stream_clients": 8, "connections_per_client": 400,
  117. "maximum_seconds": 360, "api_replicas": 8,
  118. "generation_attempts": 0, "status": "INCOMPLETE"}
  119. started = perf_counter()
  120. tasks = []
  121. finish = asyncio.Event()
  122. latencies = []
  123. clients = []
  124. try:
  125. async with asyncio.timeout(360):
  126. await environment.start(replicas=8)
  127. report["database_budget"] = environment.budget
  128. identities = await sse_accounts(scenario, 1001)
  129. ready = asyncio.Queue()
  130. admission = asyncio.Semaphore(256)
  131. preparation = asyncio.Semaphore(64)
  132. clients = [httpx.AsyncClient(
  133. base_url=environment.base_url, timeout=35, trust_env=False,
  134. limits=httpx.Limits(max_connections=400, max_keepalive_connections=400),
  135. ) for _ in range(8)]
  136. async with httpx.AsyncClient(
  137. base_url=environment.base_url, timeout=35, trust_env=False,
  138. limits=httpx.Limits(max_connections=256, max_keepalive_connections=256),
  139. ) as client:
  140. async def create(headers):
  141. async with preparation:
  142. response = await client.post(
  143. "/openapi/v1/conversations", json={"store_id": str(scenario["store"])},
  144. headers={**headers, "Idempotency-Key": uuid4().hex},
  145. )
  146. assert response.status_code == 201, response.text
  147. return "/openapi/v1/conversations/" + response.json()["conversation_id"]
  148. paths = await asyncio.gather(*(create(headers) for headers in identities))
  149. async def subscribe(index):
  150. begin = perf_counter()
  151. async with admission:
  152. selected = clients[index % len(clients)]
  153. request = selected.build_request(
  154. "GET", paths[index // 3] + "/events", headers=identities[index // 3]
  155. )
  156. response = await selected.send(request, stream=True)
  157. try:
  158. assert response.status_code == 200, (await response.aread()).decode()
  159. observed = False
  160. async for _chunk in response.aiter_raw():
  161. if not observed:
  162. observed = True
  163. latencies.append(perf_counter() - begin)
  164. ready.put_nowait(index)
  165. if finish.is_set():
  166. break
  167. assert finish.is_set(), "保持窗口内 SSE 连接提前结束"
  168. finally:
  169. await response.aclose()
  170. async with asyncio.TaskGroup() as group:
  171. for index in range(3000):
  172. tasks.append(group.create_task(subscribe(index)))
  173. for _ in range(3000):
  174. await ready.get()
  175. report["admission_latency"] = percentiles(latencies)
  176. assert await state.zcard("sse:global") == 3000
  177. response = await client.get(paths[-1] + "/events", headers=identities[-1])
  178. assert response.status_code == 429 and response.json()["code"] == "SSE_LIMIT"
  179. response = await client.get(paths[0] + "/events", headers=identities[0])
  180. assert response.status_code == 429
  181. await asyncio.sleep(10)
  182. assert await state.zcard("sse:global") == 3000
  183. assert (await client.get("/health/ready")).status_code == 200
  184. disconnected_at = perf_counter()
  185. finish.set()
  186. for task in tasks:
  187. task.cancel()
  188. await wait_released(state, 65)
  189. report["disconnect_release_seconds"] = perf_counter() - disconnected_at
  190. report["recovery_observation_window_seconds"] = 65
  191. async with client.stream("GET", paths[0] + "/events", headers=identities[0]) as row:
  192. assert row.status_code == 200
  193. await wait_released(state, 65)
  194. report.update(sse_status="PASSED", connections=3000, held_seconds=10,
  195. http_429=2, disconnect_recovery=True)
  196. async with scenario["app"].state.sessions() as session:
  197. attempts = await session.scalar(
  198. select(func.count()).select_from(ModelAttempt).where(
  199. ModelAttempt.tenant_id == scenario["tenant"].bytes
  200. )
  201. )
  202. assert attempts == 0
  203. report["status"] = "PASSED"
  204. finally:
  205. finish.set()
  206. for task in tasks:
  207. task.cancel()
  208. await asyncio.gather(*tasks, return_exceptions=True)
  209. await asyncio.gather(*(client.aclose() for client in clients))
  210. try:
  211. await wait_released(state, 65)
  212. finally:
  213. await environment.close()
  214. report.update(seconds=perf_counter() - started, observed_connections=len(latencies),
  215. temporary_resources_remaining=0)
  216. await asyncio.to_thread(Path(".work/dynamic-capacity.json").write_text,
  217. json.dumps(report, indent=2) + "\n", encoding="utf-8"
  218. )
  219. async def test_bounded_orders_with_dynamic_replicas(scenario): # noqa: F811
  220. environment = ScaleEnvironment()
  221. report = {"maximum_load_requests": 6000, "maximum_seconds": 180, "api_replicas": 2,
  222. "generation_attempts": 0, "status": "INCOMPLETE"}
  223. try:
  224. async with asyncio.timeout(180):
  225. await environment.start()
  226. report["database_budget"] = environment.budget
  227. users = await accounts(scenario)
  228. async with httpx.AsyncClient(
  229. base_url=environment.base_url, timeout=15, trust_env=False,
  230. limits=httpx.Limits(max_connections=32),
  231. ) as client:
  232. report["orders"] = await measure(
  233. client, "GET", f"/openapi/v1/orders?store_id={scenario['store']}",
  234. None, 32, users, seconds=30, maximum_requests=6000,
  235. )
  236. assert set(report["orders"]["statuses"]) == {"200"}
  237. report["status"] = "PASSED"
  238. finally:
  239. await environment.close()
  240. report["temporary_resources_remaining"] = 0
  241. await asyncio.to_thread(
  242. Path(".work/dynamic-orders.json").write_text,
  243. json.dumps(report, indent=2) + "\n", encoding="utf-8",
  244. )