controls.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. """压力测试安全控制(任务书 §16 / §20 / §21 验收表)。
  2. 不变量(对应第三阶段验收表):
  3. - 压力测试默认关闭:LoadGuard 初始 idle,无完整计划不可启动;
  4. - §16 执行前十项必填,缺一拒绝;共享环境须负责人确认(confirmed);
  5. - 参数保守上限,提高须 escalationConfirm 显式二次确认,且不得越过硬上限;
  6. - 达到连续错误 / 超时 / 429 阈值自动停止;不自动无限重试;
  7. - 立即停止:stop 信号 + 在途请求收尾等待 + 无残留负载断言;
  8. - 每轮唯一 runId;P3 行构造禁止携带 tenantId(§20 安全限制)。
  9. """
  10. from __future__ import annotations
  11. import threading
  12. import time
  13. import uuid
  14. CHANNELS = ("DB_SYNC", "API_PULL", "API_INBOUND")
  15. # §16 执行前必填项(十项)
  16. REQUIRED_PLAN_FIELDS = (
  17. "environment", # 环境
  18. "tenant", # 测试租户
  19. "owner", # 负责人
  20. "timeWindow", # 时间窗口
  21. "maxDataRows", # 最大数据量
  22. "maxConcurrency", # 最大并发
  23. "maxQps", # 最大 QPS
  24. "maxDurationSeconds", # 最大持续时间
  25. "stopThreshold", # 停止阈值(如:连续 5 次错误/超时/429 自动停止)
  26. "cleanupPlan", # 清理方案
  27. )
  28. # 保守默认上限(§20 安全限制)。提高须 escalationConfirm=True,且不得超过 HARD_LIMITS
  29. DEFAULT_LIMITS = {
  30. "maxDataRows": 100_000, # 与 L 档一致;XL 须审批
  31. "maxConcurrency": 20,
  32. "maxQps": 50,
  33. "maxDurationSeconds": 600,
  34. "maxBatchSize": 100,
  35. "errorRate": 0.5,
  36. "maxRetries": 3,
  37. "timeoutSeconds": 30,
  38. "maxConsecutiveErrors": 5,
  39. "maxTimeouts": 5,
  40. "maxRateLimited": 5,
  41. "rateLimit": 200, # P2 Mock 每秒最大请求数
  42. "randomDelay": 10.0, # P2 随机延迟上限(秒)
  43. }
  44. # 硬上限:即使显式二次确认也不允许越过(非测试环境阻断的兜底)
  45. HARD_LIMITS = {
  46. "maxDataRows": 200_000,
  47. "maxConcurrency": 50,
  48. "maxQps": 100,
  49. "maxDurationSeconds": 1800,
  50. "maxBatchSize": 500,
  51. "errorRate": 0.9,
  52. "maxRetries": 5,
  53. "timeoutSeconds": 120,
  54. "maxConsecutiveErrors": 100,
  55. "maxTimeouts": 100,
  56. "maxRateLimited": 100,
  57. "rateLimit": 1000,
  58. "randomDelay": 60.0,
  59. }
  60. # 行内禁止携带的租户字段(§20:请求体不得携带任意 tenantId 改变目标租户)
  61. _TENANT_FIELD_NAMES = ("tenantid", "tenant_id", "tenantcode", "tenant_code",
  62. "domainid", "domain_id", "orgid", "org_id")
  63. def strip_tenant_fields(row: dict) -> dict:
  64. """剔除行内租户/组织字段,防止 payload 切换目标租户。"""
  65. return {k: v for k, v in row.items() if str(k).lower() not in _TENANT_FIELD_NAMES}
  66. class ValidationError(ValueError):
  67. pass
  68. def validate_plan(plan: dict) -> list[str]:
  69. """校验压力计划,返回错误列表(空列表 = 通过)。"""
  70. errors: list[str] = []
  71. if plan.get("channel") not in CHANNELS:
  72. errors.append("channel 必须是 DB_SYNC / API_PULL / API_INBOUND")
  73. for field in REQUIRED_PLAN_FIELDS:
  74. if plan.get(field) in (None, "", 0):
  75. errors.append(f"缺少必填项:{field}(§16 执行前必须填写)")
  76. if not bool(plan.get("confirmed")):
  77. errors.append("共享开发/UAT 环境须由负责人确认后执行(confirmed=true)")
  78. escalated = bool(plan.get("escalationConfirm"))
  79. float_keys = ("errorRate", "randomDelay")
  80. for key, cap in DEFAULT_LIMITS.items():
  81. raw = plan.get(key)
  82. if raw in (None, "", 0):
  83. continue
  84. try:
  85. value = float(raw) if key in float_keys else int(raw)
  86. except (TypeError, ValueError):
  87. errors.append(f"{key} 必须是数字,收到:{raw!r}")
  88. continue
  89. if key == "errorRate" and not 0 <= value <= 1:
  90. errors.append(f"errorRate 必须在 0-1 之间")
  91. continue
  92. if value > cap and not escalated:
  93. errors.append(f"{key}={value} 超过保守上限 {cap},需勾选「提高上限二次确认」")
  94. if value > HARD_LIMITS[key]:
  95. errors.append(f"{key}={value} 超过硬上限 {HARD_LIMITS[key]},即使二次确认也不允许")
  96. return errors
  97. class AutoStopTracker:
  98. """自动停止判定(§20 安全限制:连续错误、超时或 429 达阈值自动停止)。"""
  99. def __init__(self, consecutive_errors: int = 5, timeouts: int = 5, rate_limited: int = 5):
  100. self.max_consecutive_errors = max(1, consecutive_errors)
  101. self.max_timeouts = max(1, timeouts)
  102. self.max_rate_limited = max(1, rate_limited)
  103. self._consecutive_errors = 0
  104. self._timeouts = 0
  105. self._rate_limited = 0
  106. self._tripped_by: str | None = None
  107. self._lock = threading.Lock()
  108. def record(self, status: int) -> bool:
  109. """记录一次请求结果;返回 True 表示应自动停止。
  110. status 约定:0 = 网络超时/不可达;429 = 限流;其余为 HTTP 状态码。
  111. """
  112. with self._lock:
  113. if status == 0:
  114. self._timeouts += 1
  115. self._consecutive_errors += 1
  116. elif status == 429:
  117. self._rate_limited += 1
  118. self._consecutive_errors += 1
  119. elif status >= 400:
  120. self._consecutive_errors += 1
  121. else:
  122. self._consecutive_errors = 0
  123. if self._tripped_by is None:
  124. if self._consecutive_errors >= self.max_consecutive_errors:
  125. self._tripped_by = "consecutiveErrors"
  126. elif self._timeouts >= self.max_timeouts:
  127. self._tripped_by = "timeouts"
  128. elif self._rate_limited >= self.max_rate_limited:
  129. self._tripped_by = "rateLimited"
  130. return self._tripped_by is not None
  131. @property
  132. def tripped_by(self) -> str | None:
  133. return self._tripped_by
  134. def summary(self) -> dict:
  135. return {"consecutiveErrors": self._consecutive_errors,
  136. "timeouts": self._timeouts, "rateLimited": self._rate_limited,
  137. "thresholds": {"maxConsecutiveErrors": self.max_consecutive_errors,
  138. "maxTimeouts": self.max_timeouts,
  139. "maxRateLimited": self.max_rate_limited},
  140. "trippedBy": self._tripped_by}
  141. class LoadGuard:
  142. """压力测试生命周期守卫(进程内单例,重启即默认关闭)。
  143. 状态机:idle -> ready -> running -> stopping -> stopped
  144. """
  145. def __init__(self):
  146. self.state = "idle" # idle | ready | running | stopping | stopped
  147. self.plan: dict | None = None
  148. self.run_id: str | None = None
  149. self.started_at: str | None = None
  150. self.finished_at: str | None = None
  151. self.stop_reason: str | None = None
  152. self.stop = threading.Event()
  153. self._workers: list[threading.Thread] = []
  154. self._lock = threading.Lock()
  155. # -- 计划 ----------------------------------------------------------------
  156. def submit_plan(self, plan: dict) -> dict:
  157. """保存并校验计划。校验失败不改变当前状态。"""
  158. errors = validate_plan(plan)
  159. if errors:
  160. return {"ok": False, "errors": errors, "state": self.state}
  161. with self._lock:
  162. self.plan = dict(plan)
  163. self.state = "ready"
  164. self.stop.clear()
  165. self.stop_reason = None
  166. self.finished_at = None
  167. return {"ok": True, "state": self.state, "runId": self.plan.get("runId") or ""}
  168. # -- 生命周期 ------------------------------------------------------------
  169. def start(self, run_id: str | None = None) -> str:
  170. """ready -> running;返回本轮 runId(每轮唯一)。"""
  171. with self._lock:
  172. if self.state != "ready":
  173. raise ValidationError(f"当前状态 {self.state},无法开始(须先提交并通过计划校验)")
  174. self.run_id = run_id or f"LOAD-{uuid.uuid4().hex[:12]}"
  175. self.started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
  176. self.state = "running"
  177. self.stop.clear()
  178. return self.run_id
  179. def request_stop(self, reason: str = "manual") -> dict:
  180. """请求停止:置信号,状态 -> stopping(在途请求由执行器收尾)。"""
  181. with self._lock:
  182. self.stop_reason = self.stop_reason or reason
  183. if self.state in ("ready", "running"):
  184. self.state = "stopping"
  185. self.stop.set()
  186. return {"ok": True, "state": self.state, "reason": self.stop_reason}
  187. def finish(self, reason: str | None = None) -> dict:
  188. """执行器收尾调用:stopping/running -> stopped,并断言无残留负载。"""
  189. with self._lock:
  190. if self.state not in ("running", "stopping"):
  191. raise ValidationError(f"当前状态 {self.state},无法收尾")
  192. # 排除当前线程:P1/P3 执行器线程会注册自身为 worker,
  193. # 它在 finish() 调用瞬间必然存活,不应误报为残留
  194. current = threading.current_thread()
  195. residual = [t.name for t in self._workers
  196. if t.is_alive() and t is not current]
  197. if residual:
  198. # 在途收尾不彻底:如实上报,不伪装干净
  199. self.state = "stopped"
  200. self.finished_at = time.strftime("%Y-%m-%dT%H:%M:%S")
  201. self.stop_reason = self.stop_reason or reason or "finished"
  202. return {"ok": False, "state": self.state,
  203. "reason": self.stop_reason or "finished",
  204. "residualThreads": residual}
  205. self.state = "stopped"
  206. self.finished_at = time.strftime("%Y-%m-%dT%H:%M:%S")
  207. self.stop_reason = self.stop_reason or reason or "finished"
  208. return {"ok": True, "state": self.state, "reason": self.stop_reason,
  209. "residualThreads": []}
  210. def register_worker(self, worker: threading.Thread) -> None:
  211. with self._lock:
  212. self._workers.append(worker)
  213. def no_residual(self) -> bool:
  214. with self._lock:
  215. return all(not t.is_alive() for t in self._workers)
  216. # -- 快照 ----------------------------------------------------------------
  217. def snapshot(self) -> dict:
  218. with self._lock:
  219. return {
  220. "state": self.state,
  221. "runId": self.run_id,
  222. "startedAt": self.started_at,
  223. "finishedAt": self.finished_at,
  224. "stopReason": self.stop_reason,
  225. "planChannel": (self.plan or {}).get("channel"),
  226. "planConfirmed": bool((self.plan or {}).get("confirmed")),
  227. # 持锁内直接检查,避免重入 no_residual() 死锁(Lock 非重入)
  228. "noResidual": all(not t.is_alive() for t in self._workers),
  229. }