sse_capacity_acceptance.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import asyncio
  2. import hashlib
  3. import json
  4. import os
  5. import secrets
  6. from datetime import timedelta
  7. from pathlib import Path
  8. from statistics import median, quantiles
  9. from time import perf_counter
  10. from uuid import uuid4
  11. import httpx
  12. from sqlalchemy import func, select
  13. from test_after_sales import scenario # noqa: F401
  14. from zhifu.modules.agent_runtime.models import ModelAttempt
  15. from zhifu.modules.identity.access_models import AccessSession, Principal, StoreGrant
  16. from zhifu.modules.identity.auth import now
  17. async def wait_released(state, maximum_seconds=10):
  18. if not 1 <= maximum_seconds <= 65:
  19. raise ValueError("SSE 额度恢复等待上限必须在 1–65 秒之间")
  20. # 默认验证主动释放;代理断开验证可明确等待既有异常租约期限。
  21. for _ in range(maximum_seconds * 10):
  22. if await state.zcard("sse:global") == 0:
  23. return
  24. await asyncio.sleep(0.1)
  25. raise AssertionError("SSE 断开后额度未在指定期限内释放")
  26. async def test_two_hundred_real_sse_connections(scenario): # noqa: F811
  27. sessions, state = scenario["app"].state.sessions, scenario["app"].state.state
  28. assert await state.zcard("sse:global") == 0, "需要独立的 SSE 验收环境"
  29. identities = []
  30. # 预置67个独立验收账户及有效会话;每次HTTP请求仍执行真实服务端认证。
  31. async with sessions.begin() as session:
  32. customer = await session.get(Principal, scenario["accounts"]["customer"].bytes)
  33. for index in range(67):
  34. actor, token = uuid4(), secrets.token_urlsafe(32)
  35. session.add(
  36. Principal(
  37. id=actor.bytes,
  38. tenant_id=scenario["tenant"].bytes,
  39. username=f"sse-{index}",
  40. password_hash=customer.password_hash,
  41. kind="CUSTOMER",
  42. )
  43. )
  44. await session.flush()
  45. session.add(
  46. StoreGrant(
  47. tenant_id=scenario["tenant"].bytes,
  48. principal_id=actor.bytes,
  49. store_id=scenario["store"].bytes,
  50. permission="orders:read",
  51. )
  52. )
  53. session.add(
  54. AccessSession(
  55. token_hash=hashlib.sha256(token.encode()).digest(),
  56. tenant_id=scenario["tenant"].bytes,
  57. principal_id=actor.bytes,
  58. expires_at=now() + timedelta(minutes=10),
  59. )
  60. )
  61. identities.append({"Authorization": "Bearer " + token})
  62. ready = asyncio.Queue()
  63. finish = asyncio.Event()
  64. latencies = []
  65. statuses = []
  66. tasks = []
  67. paths = []
  68. started = perf_counter()
  69. limits = httpx.Limits(max_connections=205, max_keepalive_connections=205)
  70. async with httpx.AsyncClient(
  71. base_url=os.environ.get("ACCEPTANCE_API_URL", "http://127.0.0.1:18000"),
  72. limits=limits,
  73. timeout=30,
  74. ) as client:
  75. async def connect(path, headers):
  76. begin = perf_counter()
  77. async with client.stream("GET", path + "/events", headers=headers) as response:
  78. statuses.append(response.status_code)
  79. latencies.append((perf_counter() - begin) * 1000)
  80. error = (await response.aread()).decode() if response.status_code != 200 else ""
  81. await ready.put((response.status_code, error))
  82. assert response.status_code == 200, error
  83. assert response.headers["content-type"].startswith("text/event-stream")
  84. await finish.wait()
  85. try:
  86. # 请求上限271次:67次创建、200次订阅、2次拒绝、1次恢复订阅及1次健康检查。
  87. async with asyncio.timeout(90):
  88. for headers in identities:
  89. response = await client.post(
  90. "/openapi/v1/conversations",
  91. headers={**headers, "Idempotency-Key": str(uuid4())},
  92. json={"store_id": str(scenario["store"])},
  93. )
  94. assert response.status_code == 201, response.text
  95. paths.append("/openapi/v1/conversations/" + response.json()["conversation_id"])
  96. for index in range(200):
  97. tasks.append(
  98. asyncio.create_task(connect(paths[index // 3], identities[index // 3]))
  99. )
  100. for _ in range(200):
  101. status, error = await ready.get()
  102. assert status == 200, error
  103. assert await state.zcard("sse:global") == 200
  104. # 最后一个账户仅占两个连接,新增请求因全局200连接上限被拒绝。
  105. response = await client.get(paths[-1] + "/events", headers=identities[-1])
  106. assert response.status_code == 429 and response.json()["code"] == "SSE_LIMIT"
  107. response = await client.get(paths[0] + "/events", headers=identities[0])
  108. assert response.status_code == 429
  109. await asyncio.sleep(10)
  110. assert await state.zcard("sse:global") == 200
  111. assert (await client.get("/health/ready")).status_code == 200
  112. finish.set()
  113. await asyncio.gather(*tasks)
  114. await wait_released(state)
  115. async with client.stream(
  116. "GET", paths[0] + "/events", headers=identities[0]
  117. ) as response:
  118. assert response.status_code == 200
  119. async with sessions() as session:
  120. attempts = await session.scalar(
  121. select(func.count())
  122. .select_from(ModelAttempt)
  123. .where(
  124. ModelAttempt.tenant_id == scenario["tenant"].bytes,
  125. )
  126. )
  127. assert attempts == 0
  128. percentiles = quantiles(latencies, n=100)
  129. report = {
  130. "connections": 200,
  131. "accounts": 67,
  132. "request_limit": 271,
  133. "duration_limit_seconds": 90,
  134. "held_seconds": 10,
  135. "seconds": perf_counter() - started,
  136. "http_200": statuses.count(200),
  137. "http_429": 2,
  138. "p50_ms": median(latencies),
  139. "p95_ms": percentiles[94],
  140. "p99_ms": percentiles[98],
  141. "generation_attempts": attempts,
  142. "disconnect_recovery": True,
  143. }
  144. await asyncio.to_thread(
  145. Path(".work/sse-capacity.json").write_text,
  146. json.dumps(report, ensure_ascii=False, indent=2) + "\n",
  147. encoding="utf-8",
  148. )
  149. finally:
  150. finish.set()
  151. for task in tasks:
  152. if not task.done():
  153. task.cancel()
  154. await asyncio.gather(*tasks, return_exceptions=True)
  155. # 等待断开清理完成,确保fixture删除账户前服务端结束查询。
  156. await wait_released(state)