replica_capacity_acceptance.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import asyncio
  2. import json
  3. import platform
  4. from collections import Counter
  5. from datetime import UTC, datetime
  6. from pathlib import Path
  7. from time import perf_counter
  8. import httpx
  9. from capacity_acceptance import accounts, percentiles
  10. from retrieval_evaluation import publish_dataset
  11. from test_after_sales import scenario # noqa: F401
  12. from zhifu.modules.knowledge.vector import connection
  13. def summarize(samples, seconds):
  14. successful = [row["seconds"] for row in samples if row["status"] == "200"]
  15. return {
  16. "seconds": seconds,
  17. "requests": len(samples),
  18. "statuses": dict(Counter(row["status"] for row in samples)),
  19. "replicas": dict(Counter(row["replica"] for row in samples)),
  20. "success_rps": len(successful) / seconds,
  21. "success_latency": percentiles(successful),
  22. }
  23. async def sustained(client, method, path, bodies, identities, seconds, concurrency=32):
  24. samples = []
  25. begin = perf_counter()
  26. async def worker(index):
  27. sequence = index
  28. while perf_counter() - begin < seconds:
  29. start = perf_counter()
  30. result = (
  31. await asyncio.gather(
  32. client.request(
  33. method,
  34. path,
  35. json=bodies[sequence % len(bodies)],
  36. headers=identities[index % len(identities)],
  37. ),
  38. return_exceptions=True,
  39. )
  40. )[0]
  41. if isinstance(result, httpx.HTTPError):
  42. status, replica = type(result).__name__, "transport"
  43. elif isinstance(result, BaseException):
  44. raise result
  45. else:
  46. status = str(result.status_code)
  47. replica = result.headers.get("X-Zhifu-Replica", "direct")
  48. if status == "200":
  49. assert result.json()["items"], result.text
  50. samples.append(
  51. {
  52. "status": status,
  53. "replica": replica,
  54. "seconds": perf_counter() - start,
  55. "window": min(int((start - begin) // 10), (seconds - 1) // 10),
  56. }
  57. )
  58. sequence += concurrency
  59. await asyncio.gather(*(worker(index) for index in range(concurrency)))
  60. elapsed = perf_counter() - begin
  61. result = summarize(samples, elapsed)
  62. result["windows"] = [
  63. {
  64. "start_second": index * 10,
  65. **summarize(
  66. [row for row in samples if row["window"] == index],
  67. 10 if index < seconds // 10 - 1 else elapsed - index * 10,
  68. ),
  69. }
  70. for index in range(seconds // 10)
  71. ]
  72. return result
  73. async def test_sustained_replica_capacity(scenario): # noqa: F811
  74. try:
  75. dataset, _ = await publish_dataset(scenario)
  76. identities = await accounts(scenario)
  77. stages = []
  78. for name, url, replicas in (
  79. ("single", "http://127.0.0.1:18000", 1),
  80. ("replicas", "http://127.0.0.1:18080", 2),
  81. ):
  82. async with httpx.AsyncClient(
  83. base_url=url, timeout=35, trust_env=False, limits=httpx.Limits(max_connections=32)
  84. ) as client:
  85. for workload, method, path, bodies, seconds in (
  86. (
  87. "orders",
  88. "GET",
  89. f"/openapi/v1/orders?store_id={scenario['store']}",
  90. [None],
  91. 30,
  92. ),
  93. (
  94. "knowledge",
  95. "POST",
  96. f"/openapi/v1/stores/{scenario['store']}/knowledge/search",
  97. [{"query": question["query"]} for question in dataset["queries"]],
  98. 60,
  99. ),
  100. ):
  101. response = await client.request(
  102. method, path, json=bodies[0], headers=identities[0]
  103. )
  104. assert response.status_code == 200, response.text
  105. result = await sustained(client, method, path, bodies, identities, seconds)
  106. stages.append(
  107. {
  108. "deployment": name,
  109. "api_replicas": replicas,
  110. "inference_replicas": replicas,
  111. "workload": workload,
  112. "concurrency": 32,
  113. **result,
  114. }
  115. )
  116. report = {
  117. "checked_at": datetime.now(UTC).isoformat(),
  118. "host": platform.platform(),
  119. "accounts": 16,
  120. "documents": 12,
  121. "queries": 24,
  122. "stages": stages,
  123. }
  124. await asyncio.to_thread(
  125. Path("docs/verification/replica-capacity.json").write_text,
  126. json.dumps(report, ensure_ascii=False, indent=2),
  127. encoding="utf-8",
  128. )
  129. for stage in stages:
  130. assert set(stage["statuses"]) <= {"200", "429"}, stage
  131. assert stage["statuses"].get("200", 0) > 0, stage
  132. if stage["deployment"] == "replicas":
  133. assert set(stage["replicas"]) == {"api1", "api2"}, stage
  134. finally:
  135. with connection() as (client, collection):
  136. client.delete(collection, filter=f'tenant == "{scenario["tenant"].hex}"')