test_load_metrics.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. """第三阶段压力指标、P1 批量生成、P2 压力模式测试(任务书 §17/§18/§19)。
  2. 全部进程内验证(纯函数/TestClient),不依赖 Ai-DOP 在线与真实 MySQL。
  3. """
  4. from __future__ import annotations
  5. import threading
  6. import time
  7. from concurrent.futures import ThreadPoolExecutor
  8. import pytest
  9. fastapi_testclient = pytest.importorskip("fastapi.testclient")
  10. TestClient = fastapi_testclient.TestClient
  11. from load import metrics as lm # noqa: E402
  12. from providers import mock_api # noqa: E402
  13. from providers import mock_db # noqa: E402
  14. # ---------------------------------------------------------------------------
  15. # percentile(§17 P50/P95/P99 计算)
  16. # ---------------------------------------------------------------------------
  17. def test_percentile_linear_interpolation():
  18. assert lm.percentile([1.0, 2.0, 3.0, 4.0], 50) == 2.5
  19. assert lm.percentile([1.0, 2.0, 3.0, 4.0], 25) == 1.75
  20. def test_percentile_boundaries_and_empty():
  21. assert lm.percentile([1.0, 2.0, 3.0], 0) == 1.0
  22. assert lm.percentile([1.0, 2.0, 3.0], 100) == 3.0
  23. assert lm.percentile([], 50) == 0.0
  24. assert lm.percentile([5.0], 95) == 5.0
  25. def test_percentile_p95_p99_of_100_values():
  26. vals = [float(i) for i in range(100)]
  27. assert lm.percentile(vals, 95) == 94.05
  28. assert lm.percentile(vals, 99) == 98.01
  29. # ---------------------------------------------------------------------------
  30. # LoadMetrics(§17 指标采集与快照)
  31. # ---------------------------------------------------------------------------
  32. def test_record_classifies_statuses_and_snapshot():
  33. m = lm.LoadMetrics()
  34. m.mark_start()
  35. m.record(200, 0.01, rows=1)
  36. m.record(201, 0.02)
  37. m.record(202, 0.03, batches=2)
  38. m.record(500, 0.04)
  39. m.record(0, 0.05)
  40. m.record(429, 0.06)
  41. m.mark_finish()
  42. s = m.snapshot()
  43. assert s["totalRequests"] == 6
  44. assert s["success"] == 3 and s["failed"] == 3
  45. assert s["successRate"] == 0.5
  46. assert s["timeouts"] == 1 and s["rateLimited"] == 1
  47. assert s["httpErrors"] == {500: 1}
  48. assert s["totalBatches"] == 7
  49. assert s["totalRows"] == 1
  50. assert s["avgMs"] == 35.0
  51. assert s["maxMs"] == 60.0
  52. assert s["p50Ms"] == 35.0
  53. assert s["firstFailureAt"] is not None
  54. assert s["startedAt"] is not None and s["finishedAt"] is not None
  55. def test_snapshot_contains_all_metric_fields():
  56. m = lm.LoadMetrics()
  57. m.mark_start()
  58. m.record(200, 0.1, rows=2)
  59. m.mark_finish()
  60. s = m.snapshot()
  61. for field, _ in lm.METRIC_FIELDS:
  62. assert field in s, f"missing metric field: {field}"
  63. assert s["totalRequests"] == 1 and s["successRate"] == 1.0
  64. def test_actual_qps_empty_and_completion_window_pruning():
  65. m = lm.LoadMetrics(window_seconds=10)
  66. assert m.actual_qps() == 0.0
  67. now = time.monotonic()
  68. with m._lock:
  69. m._completion_times[:] = [now - 100, now - 5, now - 0.5]
  70. pruned = m._pruned_completion_times(now)
  71. assert pruned == [now - 5, now - 0.5]
  72. def test_row_throughput_uses_wall_clock_span():
  73. m = lm.LoadMetrics()
  74. m.mark_start()
  75. m.record(200, 0.001, rows=10)
  76. m.mark_finish()
  77. with m._lock:
  78. m.started_at = "2026-01-01T10:00:00"
  79. m.finished_at = "2026-01-01T10:00:10"
  80. assert m.row_throughput() == 1.0
  81. def test_snapshot_freezes_qps_after_finish():
  82. # 停止后快照的 QPS 必须定格:滚动窗口分母不再随时间变大(用户报告“停止后 QPS 还在变”)
  83. m = lm.LoadMetrics(window_seconds=10)
  84. m.mark_start()
  85. m.record(200, 0.01)
  86. m.record(200, 0.01)
  87. m.mark_finish()
  88. s1 = m.snapshot()
  89. time.sleep(0.15)
  90. s2 = m.snapshot()
  91. assert s1["actualQps"] > 0
  92. assert s1["actualQps"] == s2["actualQps"]
  93. assert s1["rowThroughput"] == s2["rowThroughput"]
  94. def test_mark_start_resets_previous_round_metrics():
  95. # 单例跨轮复用:新一轮 mark_start 必须清空上一轮累计(防止多轮指标叠加)
  96. m = lm.LoadMetrics()
  97. m.mark_start()
  98. m.record(200, 0.01, rows=3)
  99. m.mark_finish()
  100. m.mark_start()
  101. s = m.snapshot()
  102. assert s["totalRequests"] == 0
  103. assert s["totalRows"] == 0
  104. assert s["startedAt"] is not None
  105. assert s["finishedAt"] is None
  106. def test_metrics_thread_safe_concurrent_records():
  107. m = lm.LoadMetrics()
  108. def hit(i: int) -> None:
  109. m.record(200 if i % 3 else 500, 0.001)
  110. with ThreadPoolExecutor(max_workers=8) as pool:
  111. list(pool.map(hit, range(300)))
  112. s = m.snapshot()
  113. assert s["totalRequests"] == 300
  114. assert s["success"] == 200 and s["failed"] == 100
  115. assert s["httpErrors"] == {500: 100}
  116. # ---------------------------------------------------------------------------
  117. # P1 批量生成 generate_row_blocks(§18:档位/增量比例/字段宽度/一键停止)
  118. # ---------------------------------------------------------------------------
  119. def test_p1_row_blocks_chunked_by_batch_size():
  120. rows = [{"bizKey": "B", "sourceUpdatedAt": "2026-01-01T00:00:00"}]
  121. blocks = list(mock_db.generate_row_blocks(rows, 2500, batch_size=1000))
  122. assert [len(b) for b in blocks] == [1000, 1000, 500]
  123. def test_p1_row_blocks_bizkey_unique_with_full_increment():
  124. rows = [{"bizKey": "B"}]
  125. blocks = list(mock_db.generate_row_blocks(rows, 50, increment_ratio=1.0, batch_size=10))
  126. keys = [r["bizKey"] for b in blocks for r in b]
  127. assert len(set(keys)) == 50
  128. assert all(k.startswith("B#LOAD-") for k in keys)
  129. def test_p1_row_blocks_increment_ratio_reuses_template_keys():
  130. rows = [{"bizKey": "B"}]
  131. blocks = list(mock_db.generate_row_blocks(rows, 10, increment_ratio=0.5, batch_size=100))
  132. keys = [r["bizKey"] for b in blocks for r in b]
  133. assert keys[:5] == [f"B#LOAD-{i}" for i in range(5)]
  134. assert keys[5:] == ["B"] * 5
  135. def test_p1_row_blocks_pads_text_fields():
  136. rows = [{"bizKey": "B", "note": "hi"}]
  137. (block,) = list(mock_db.generate_row_blocks(rows, 1, field_width=8, batch_size=10))
  138. row = block[0]
  139. assert row["note"] == "hixxxxxx"
  140. assert len(row["bizKey"]) >= 8
  141. def test_p1_row_blocks_increment_column_advances():
  142. rows = [{"bizKey": "B", "sourceUpdatedAt": "2026-01-01T00:00:00"}]
  143. blocks = list(mock_db.generate_row_blocks(rows, 100, batch_size=30))
  144. vals = [r["sourceUpdatedAt"] for b in blocks for r in b]
  145. assert len(set(vals)) == 100
  146. def test_p1_row_blocks_stop_event_before_and_midway():
  147. rows = [{"bizKey": "B"}]
  148. stop = threading.Event()
  149. stop.set()
  150. assert list(mock_db.generate_row_blocks(rows, 100, stop_event=stop)) == []
  151. stop2 = threading.Event()
  152. gen = mock_db.generate_row_blocks(rows, 100, batch_size=10, stop_event=stop2)
  153. first = next(gen)
  154. stop2.set()
  155. assert len(first) == 10
  156. assert list(gen) == []
  157. # ---------------------------------------------------------------------------
  158. # P2 压力模式(§19:分页截断/错误比例/限流/统计/恢复)
  159. # ---------------------------------------------------------------------------
  160. @pytest.fixture()
  161. def p2client():
  162. state = mock_api.MockApiState()
  163. state.settings.auth_mode = "NONE"
  164. return TestClient(mock_api.create_app(state)), state
  165. def _override(c, path: str, n: int) -> None:
  166. c.post("/__control/overrides",
  167. json={"path": path, "rows": [{"bizKey": f"K{i}", "v": i} for i in range(n)]})
  168. def test_p2_page_size_applies_only_in_load_mode(p2client):
  169. c, _ = p2client
  170. _override(c, "/api/load-a", 10)
  171. c.post("/__control/settings", json={"page_size": 3})
  172. assert len(c.get("/api/load-a").json()["data"]["list"]) == 10 # 未开压力模式不截断
  173. c.post("/__control/settings", json={"load_mode": True})
  174. assert len(c.get("/api/load-a").json()["data"]["list"]) == 3
  175. def test_p2_total_rows_limit_truncates_in_load_mode(p2client):
  176. c, _ = p2client
  177. _override(c, "/api/load-b", 10)
  178. c.post("/__control/settings", json={"load_mode": True, "total_rows_limit": 5})
  179. assert len(c.get("/api/load-b").json()["data"]["list"]) == 5
  180. def test_p2_error_rate_injects_500(p2client):
  181. c, _ = p2client
  182. _override(c, "/api/load-c", 3)
  183. c.post("/__control/settings", json={"load_mode": True, "error_rate": 1.0})
  184. assert c.get("/api/load-c").status_code == 500
  185. def test_p2_rate_limit_returns_429_beyond_threshold(p2client):
  186. c, _ = p2client
  187. _override(c, "/api/load-d", 3)
  188. c.post("/__control/settings", json={"load_mode": True, "rate_limit": 1})
  189. assert c.get("/api/load-d").status_code == 200
  190. assert c.get("/api/load-d").status_code == 429
  191. def test_p2_load_stats_tracks_requests_and_errors(p2client):
  192. c, _ = p2client
  193. _override(c, "/api/load-e", 3)
  194. c.post("/__control/settings", json={"load_mode": True, "error_rate": 1.0})
  195. c.get("/api/load-e")
  196. stats = c.get("/__control/load-stats").json()
  197. assert stats["loadMode"] is True
  198. assert stats["stats"]["requests"] == 1
  199. # JSON 序列化后状态码键为字符串
  200. assert stats["stats"]["statusCounts"] == {"500": 1}
  201. assert set(stats["stats"]) >= {
  202. "requests", "active", "peakConcurrency", "qpsWindow",
  203. "avgMs", "maxMs", "statusCounts", "rateLimited"}
  204. def test_p2_reset_clears_pressure_settings(p2client):
  205. c, _ = p2client
  206. c.post("/__control/settings", json={
  207. "load_mode": True, "page_size": 2, "total_rows_limit": 4,
  208. "random_delay": 1.5, "error_rate": 0.5, "rate_limit": 10})
  209. c.post("/__control/settings", json={"reset": True})
  210. s = c.get("/__control/settings").json()
  211. assert s["loadMode"] is False
  212. assert s["pageSize"] == 0 and s["totalRowsLimit"] == 0
  213. assert s["randomDelay"] == 0.0 and s["errorRate"] == 0.0 and s["rateLimit"] == 0.0