test_load_controls.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """第三阶段压力测试安全控制与 P3 执行器测试(任务书 §16/§20/§21 验收表)。"""
  2. from __future__ import annotations
  3. import threading
  4. import time
  5. import pytest
  6. from catalog import sample_adapters as sa
  7. from load import controls
  8. from load.metrics import LoadMetrics
  9. from load.runner_p3 import P3Runner, _RateLimiter
  10. def _valid_plan(**over) -> dict:
  11. plan = {
  12. "channel": "API_INBOUND",
  13. "environment": "dev", "tenant": "T1", "owner": "qa", "timeWindow": "t",
  14. "maxDataRows": 1000, "maxConcurrency": 4, "maxQps": 10, "maxDurationSeconds": 5,
  15. "stopThreshold": "连续 5 次错误/超时/429 自动停止", "cleanupPlan": "clean",
  16. "confirmed": True,
  17. }
  18. plan.update(over)
  19. return plan
  20. # ---------------------------------------------------------------------------
  21. # validate_plan(§16 十项必填 / 上限 / 二次确认)
  22. # ---------------------------------------------------------------------------
  23. def test_validate_plan_accepts_complete_plan():
  24. assert controls.validate_plan(_valid_plan()) == []
  25. def test_validate_plan_rejects_missing_required_fields():
  26. errors = controls.validate_plan({"channel": "API_INBOUND"})
  27. assert len(errors) >= 10
  28. assert any("environment" in e for e in errors)
  29. def test_validate_plan_requires_owner_confirmation():
  30. errors = controls.validate_plan(_valid_plan(confirmed=False))
  31. assert any("负责人确认" in e for e in errors)
  32. def test_validate_plan_blocks_over_limit_without_escalation():
  33. errors = controls.validate_plan(_valid_plan(maxQps=999))
  34. assert any("超过保守上限" in e for e in errors)
  35. def test_validate_plan_allows_escalation_but_blocks_hard_limit():
  36. ok = controls.validate_plan(_valid_plan(maxQps=60, escalationConfirm=True))
  37. assert ok == []
  38. errors = controls.validate_plan(_valid_plan(maxQps=10000, escalationConfirm=True))
  39. assert any("硬上限" in e for e in errors)
  40. def test_validate_plan_rejects_bad_channel_and_error_rate_range():
  41. errors = controls.validate_plan(_valid_plan(channel="NOPE"))
  42. assert any("channel" in e for e in errors)
  43. errors = controls.validate_plan(_valid_plan(errorRate=1.5))
  44. assert any("errorRate" in e for e in errors)
  45. # ---------------------------------------------------------------------------
  46. # 租户字段剔除(§20:请求体不得携带任意 tenantId)
  47. # ---------------------------------------------------------------------------
  48. def test_strip_tenant_fields_removes_tenant_keys():
  49. row = {"ItemNum": "A", "tenantId": "999", "tenant_id": "888",
  50. "Domain": "SIM", "bizKey": "K1"}
  51. stripped = controls.strip_tenant_fields(dict(row))
  52. assert "tenantId" not in stripped and "tenant_id" not in stripped
  53. assert stripped["Domain"] == "SIM" and stripped["bizKey"] == "K1"
  54. # ---------------------------------------------------------------------------
  55. # AutoStopTracker(§20 自动停止阈值)
  56. # ---------------------------------------------------------------------------
  57. def test_auto_stop_tracker_consecutive_errors():
  58. tracker = controls.AutoStopTracker(consecutive_errors=3)
  59. assert not tracker.record(202)
  60. assert not tracker.record(500)
  61. assert not tracker.record(500)
  62. assert tracker.record(500)
  63. assert tracker.tripped_by == "consecutiveErrors"
  64. def test_auto_stop_tracker_timeouts_and_429():
  65. tracker = controls.AutoStopTracker(timeouts=2, rate_limited=2)
  66. tracker.record(0)
  67. assert tracker.record(0) and tracker.tripped_by == "timeouts"
  68. tracker2 = controls.AutoStopTracker(timeouts=2, rate_limited=2)
  69. tracker2.record(429)
  70. assert tracker2.record(429) and tracker2.tripped_by == "rateLimited"
  71. def test_auto_stop_tracker_success_resets_consecutive():
  72. tracker = controls.AutoStopTracker(consecutive_errors=3)
  73. tracker.record(500)
  74. tracker.record(500)
  75. tracker.record(202)
  76. assert not tracker.record(500)
  77. assert not tracker.record(500)
  78. # ---------------------------------------------------------------------------
  79. # LoadGuard 生命周期(默认关闭 / 立即停止 / 无残留)
  80. # ---------------------------------------------------------------------------
  81. def test_guard_default_state_is_idle_and_blocks_start():
  82. guard = controls.LoadGuard()
  83. assert guard.state == "idle"
  84. with pytest.raises(controls.ValidationError):
  85. guard.start()
  86. assert guard.snapshot()["noResidual"] is True
  87. def test_guard_lifecycle_ready_running_stopping_stopped():
  88. guard = controls.LoadGuard()
  89. assert guard.submit_plan(_valid_plan())["ok"]
  90. assert guard.state == "ready"
  91. run_id = guard.start()
  92. assert guard.state == "running" and run_id.startswith("LOAD-")
  93. assert guard.request_stop("manual")["state"] == "stopping"
  94. result = guard.finish()
  95. assert result["ok"] and result["residualThreads"] == []
  96. assert guard.state == "stopped"
  97. def test_guard_finish_reports_residual_worker():
  98. guard = controls.LoadGuard()
  99. guard.submit_plan(_valid_plan())
  100. guard.start()
  101. worker = threading.Thread(target=lambda: time.sleep(0.2), daemon=True)
  102. worker.start()
  103. guard.register_worker(worker)
  104. guard.request_stop()
  105. result = guard.finish()
  106. assert not result["ok"] and "residualThreads" in result
  107. worker.join(timeout=2)
  108. def test_guard_finish_excludes_current_thread_from_residual():
  109. # P1/P3 执行器线程注册自身为 worker 并在自身线程内 finish(),
  110. # 不得把当前线程误报为残留(回归:曾导致每次收尾都报 residualThreads)
  111. guard = controls.LoadGuard()
  112. guard.submit_plan(_valid_plan())
  113. guard.start()
  114. result = {}
  115. def _finish_in_worker():
  116. guard.register_worker(threading.current_thread())
  117. guard.request_stop()
  118. result.update(guard.finish())
  119. worker = threading.Thread(target=_finish_in_worker, daemon=True)
  120. worker.start()
  121. worker.join(timeout=5)
  122. assert result["ok"] is True and result["residualThreads"] == []
  123. assert guard.state == "stopped"
  124. # ---------------------------------------------------------------------------
  125. # P3 执行器(固定 QPS 短跑 / 立即停止无残留 / 自动停止 / 错误比例注入)
  126. # ---------------------------------------------------------------------------
  127. class _FakeInboundClient:
  128. """进程内假客户端:捕获调用并返回固定状态,不触网。"""
  129. def __init__(self, status: int = 202):
  130. self.status = status
  131. self.calls: list[tuple] = []
  132. def op_push(self, entity, envelope, **kwargs):
  133. self.calls.append(("push", entity, envelope, kwargs))
  134. return self.status, {"ok": True}, {}
  135. def op_bulk(self, entity, lines, **kwargs):
  136. self.calls.append(("bulk", entity, lines, kwargs))
  137. return self.status, {"ok": True}, {}
  138. def _p3_env(plan_over: dict | None = None, client_status: int = 202):
  139. guard = controls.LoadGuard()
  140. metrics = LoadMetrics()
  141. client = _FakeInboundClient(status=client_status)
  142. obj = next(o for o in sa.load_catalog()["objects"]
  143. if (o.get("apiInbound") or {}).get("supported"))
  144. entity = obj["apiInbound"]["entityCode"]
  145. rows = sa.adapt_inbound_rows(obj, "T-LOAD")["rows"]
  146. plan = _valid_plan(**{"entityCode": entity, "runId": "T-LOAD"})
  147. plan.update(plan_over or {})
  148. return guard, metrics, client, entity, rows, plan
  149. def test_p3_runner_short_run_records_metrics_and_finishes_clean():
  150. guard, metrics, client, entity, rows, plan = _p3_env(
  151. {"maxDurationSeconds": 1, "maxQps": 30, "maxConcurrency": 4,
  152. "maxConsecutiveErrors": 5, "maxTimeouts": 5, "maxRateLimited": 5})
  153. guard.submit_plan(plan)
  154. guard.start()
  155. runner = P3Runner(client, entity, rows, plan, guard, metrics)
  156. runner.start()
  157. runner.join(timeout=10)
  158. assert not runner.is_alive()
  159. assert guard.state == "stopped"
  160. assert guard.snapshot()["noResidual"] is True
  161. snap = metrics.snapshot()
  162. assert snap["totalRequests"] > 0
  163. assert snap["success"] == snap["totalRequests"]
  164. assert snap["successRate"] == 1.0
  165. assert snap["p50Ms"] >= 0 and snap["startedAt"] and snap["finishedAt"]
  166. def test_p3_runner_immediate_stop_leaves_no_residual():
  167. guard, metrics, client, entity, rows, plan = _p3_env(
  168. {"maxDurationSeconds": 10, "maxQps": 5, "maxConcurrency": 2})
  169. guard.submit_plan(plan)
  170. guard.start()
  171. runner = P3Runner(client, entity, rows, plan, guard, metrics)
  172. runner.start()
  173. time.sleep(0.2)
  174. guard.request_stop("manual stop")
  175. runner.join(timeout=10)
  176. assert guard.state == "stopped"
  177. assert guard.snapshot()["noResidual"] is True
  178. assert metrics.snapshot()["totalRequests"] < 100
  179. def test_p3_runner_stop_cancels_queued_futures():
  180. # 回归:stop 后只等在途请求,排队未启动的任务必须取消(否则慢请求下收尾拖沓)
  181. guard, metrics, client, entity, rows, plan = _p3_env(
  182. {"maxDurationSeconds": 30, "maxQps": 50, "maxConcurrency": 2})
  183. guard.submit_plan(plan)
  184. guard.start()
  185. def slow_push(entity, envelope, **kwargs):
  186. time.sleep(0.5)
  187. client.calls.append(("push", entity, envelope, kwargs))
  188. return 202, {"ok": True}, {}
  189. client.op_push = slow_push
  190. runner = P3Runner(client, entity, rows, plan, guard, metrics)
  191. runner.start()
  192. time.sleep(1.0)
  193. guard.request_stop("manual stop")
  194. started = time.monotonic()
  195. runner.join(timeout=10)
  196. elapsed = time.monotonic() - started
  197. assert not runner.is_alive()
  198. assert guard.state == "stopped"
  199. assert elapsed < 5, f"停止收尾过慢:{elapsed:.1f}s(排队任务未被取消?)"
  200. assert guard.snapshot()["noResidual"] is True
  201. def test_p3_runner_auto_stops_on_consecutive_errors():
  202. guard, metrics, client, entity, rows, plan = _p3_env(
  203. {"maxDurationSeconds": 30, "maxQps": 50, "maxConcurrency": 2,
  204. "maxConsecutiveErrors": 2},
  205. client_status=500)
  206. guard.submit_plan(plan)
  207. guard.start()
  208. runner = P3Runner(client, entity, rows, plan, guard, metrics)
  209. runner.start()
  210. runner.join(timeout=10)
  211. assert "自动停止" in (guard.stop_reason or "")
  212. assert guard.state == "stopped"
  213. assert metrics.snapshot()["failed"] >= 2
  214. def test_p3_runner_error_rate_injects_missing_required_field():
  215. # errorRate 硬上限 0.9;提交计划后改回 1.0 保证每行都缺必填字段(确定性断言注入逻辑)
  216. guard, metrics, client, entity, rows, plan = _p3_env(
  217. {"maxDurationSeconds": 1, "maxQps": 10, "maxConcurrency": 2,
  218. "errorRate": 0.9, "escalationConfirm": True})
  219. guard.submit_plan(plan)
  220. plan["errorRate"] = 1.0
  221. guard.start()
  222. runner = P3Runner(client, entity, rows, plan, guard, metrics)
  223. runner.start()
  224. runner.join(timeout=10)
  225. assert client.calls, "压测应发出请求"
  226. first_required = runner._first_required
  227. for call in client.calls[:3]:
  228. envelope = call[2]
  229. assert first_required not in envelope["data"]["list"][0]
  230. def test_p3_runner_never_injects_tenant_fields():
  231. guard, metrics, client, entity, rows, plan = _p3_env(
  232. {"maxDurationSeconds": 1, "maxQps": 10, "maxConcurrency": 2})
  233. guard.submit_plan(plan)
  234. guard.start()
  235. runner = P3Runner(client, entity, rows, plan, guard, metrics)
  236. runner.start()
  237. runner.join(timeout=10)
  238. for call in client.calls:
  239. row = call[2]["data"]["list"][0]
  240. assert "tenantId" not in row and "tenant_id" not in row
  241. def test_rate_limiter_caps_qps_approximately():
  242. limiter = _RateLimiter(qps=100)
  243. started = time.monotonic()
  244. for _ in range(5):
  245. limiter.wait()
  246. elapsed = time.monotonic() - started
  247. # 5 个槽位应至少占 4 个间隔(4*10ms),宽松断言避免 CI 抖动误报
  248. assert elapsed >= 0.03