capacity_acceptance.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import asyncio
  2. import json
  3. import platform
  4. from collections import Counter
  5. from pathlib import Path
  6. from statistics import median, quantiles
  7. from time import perf_counter
  8. from uuid import uuid4
  9. import httpx
  10. from retrieval_evaluation import publish_dataset
  11. from test_after_sales import scenario # noqa: F401
  12. from zhifu.modules.identity.access_models import Principal, StoreGrant
  13. from zhifu.modules.identity.auth import password_hasher
  14. from zhifu.modules.knowledge.vector import connection
  15. def percentiles(values):
  16. if not values:
  17. return None
  18. boundaries = quantiles(values, n=100, method="inclusive") if len(values) > 1 else values * 99
  19. return {
  20. "p50_ms": median(values) * 1000,
  21. "p95_ms": boundaries[94] * 1000,
  22. "p99_ms": boundaries[98] * 1000,
  23. }
  24. async def accounts(context):
  25. password = "capacity-" + str(uuid4())
  26. encoded = await asyncio.to_thread(password_hasher.hash, password)
  27. identifiers = [uuid4() for _ in range(16)]
  28. async with context["app"].state.sessions.begin() as session:
  29. for identifier in identifiers:
  30. session.add(
  31. Principal(
  32. id=identifier.bytes,
  33. tenant_id=context["tenant"].bytes,
  34. username="capacity-" + identifier.hex,
  35. kind="CUSTOMER",
  36. password_hash=encoded,
  37. )
  38. )
  39. await session.flush()
  40. session.add(
  41. StoreGrant(
  42. tenant_id=context["tenant"].bytes,
  43. principal_id=identifier.bytes,
  44. store_id=context["store"].bytes,
  45. permission="orders:read",
  46. )
  47. )
  48. headers = []
  49. for identifier in identifiers:
  50. response = await context["client"].post(
  51. "/openapi/v1/auth/sessions",
  52. json={
  53. "tenant_id": str(context["tenant"]),
  54. "username": "capacity-" + identifier.hex,
  55. "password": password,
  56. },
  57. )
  58. assert response.status_code == 200, response.text
  59. headers.append({"Authorization": "Bearer " + response.json()["access_token"]})
  60. response = await context["call"](
  61. "PUT",
  62. f"/internal/v1/orders/{uuid4()}",
  63. "connector",
  64. {**context["source"], "subject_id": str(identifier)},
  65. )
  66. assert response.status_code == 200, response.text
  67. return headers
  68. async def measure(
  69. client, method, path, body, concurrency, identities, seconds=5, maximum_requests=2000
  70. ):
  71. if not 1 <= concurrency <= 64 or not 1 <= maximum_requests <= 100000:
  72. raise ValueError("并发或请求上限无效")
  73. samples = []
  74. issued = 0
  75. started = perf_counter()
  76. deadline = started + seconds
  77. async def worker(index):
  78. nonlocal issued
  79. while perf_counter() < deadline and issued < maximum_requests:
  80. # 当前事件循环内先占用请求名额,所有并发工作共享同一总数上限。
  81. issued += 1
  82. begin = perf_counter()
  83. result = (
  84. await asyncio.gather(
  85. client.request(
  86. method, path, json=body, headers=identities[index % len(identities)]
  87. ),
  88. return_exceptions=True,
  89. )
  90. )[0]
  91. elapsed = perf_counter() - begin
  92. if isinstance(result, httpx.HTTPError):
  93. samples.append(
  94. {
  95. "status": "transport_error",
  96. "seconds": elapsed,
  97. "error": type(result).__name__,
  98. }
  99. )
  100. elif isinstance(result, BaseException):
  101. raise result
  102. else:
  103. samples.append({"status": str(result.status_code), "seconds": elapsed})
  104. async with asyncio.timeout(seconds + 35):
  105. await asyncio.gather(*(worker(index) for index in range(concurrency)))
  106. duration = perf_counter() - started
  107. successes = [row["seconds"] for row in samples if row["status"] == "200"]
  108. return {
  109. "concurrency": concurrency,
  110. "account_count": len(identities),
  111. "duration_seconds": duration,
  112. "requests": len(samples),
  113. "maximum_requests": maximum_requests,
  114. "maximum_seconds": seconds + 35,
  115. "statuses": dict(Counter(row["status"] for row in samples)),
  116. "all_rps": len(samples) / duration,
  117. "success_rps": len(successes) / duration,
  118. "success_latency": percentiles(successes),
  119. "all_latency": percentiles([row["seconds"] for row in samples]),
  120. }
  121. async def test_capacity(scenario): # noqa: F811
  122. try:
  123. await publish_dataset(scenario)
  124. identities = await accounts(scenario)
  125. async with httpx.AsyncClient(
  126. base_url="http://127.0.0.1:18000",
  127. headers=scenario["headers"]["customer"],
  128. timeout=30,
  129. trust_env=False,
  130. limits=httpx.Limits(max_connections=32),
  131. ) as client:
  132. report = {
  133. "environment": platform.platform(),
  134. "python": platform.python_version(),
  135. "api_workers": 1,
  136. "account_count": 17,
  137. "database_pool": "5+5",
  138. "inference_device": "cpu",
  139. "documents": 12,
  140. "stages": [],
  141. }
  142. for name, method, path, body, levels, users in (
  143. (
  144. "orders",
  145. "GET",
  146. f"/openapi/v1/orders?store_id={scenario['store']}",
  147. None,
  148. (1, 4, 16, 32),
  149. [scenario["headers"]["customer"]],
  150. ),
  151. (
  152. "orders_multi_account",
  153. "GET",
  154. f"/openapi/v1/orders?store_id={scenario['store']}",
  155. None,
  156. (4, 16, 32),
  157. identities,
  158. ),
  159. (
  160. "knowledge",
  161. "POST",
  162. f"/openapi/v1/stores/{scenario['store']}/knowledge/search",
  163. {"query": "键盘脏了能放水里洗吗"},
  164. (1, 4, 16),
  165. [scenario["headers"]["customer"]],
  166. ),
  167. ):
  168. response = await client.request(method, path, json=body)
  169. assert response.status_code == 200, response.text
  170. for concurrency in levels:
  171. result = await measure(client, method, path, body, concurrency, users)
  172. report["stages"].append({"workload": name, **result})
  173. await asyncio.to_thread(
  174. Path("docs/verification/capacity-results.json").write_text,
  175. json.dumps(report, ensure_ascii=False, indent=2),
  176. encoding="utf-8",
  177. )
  178. for stage in report["stages"]:
  179. assert set(stage["statuses"]) <= {"200", "429"}, stage
  180. assert stage["statuses"].get("200", 0) > 0, stage
  181. finally:
  182. with connection() as (client, collection):
  183. client.delete(collection, filter=f'tenant == "{scenario["tenant"].hex}"')