runner_p3.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """方式丙压测执行器(任务书 §20):模拟器是负载发起方。
  2. 能力映射:
  3. - 并发 worker(ThreadPoolExecutor,plan.maxConcurrency);
  4. - QPS 限制(槽位法节流器,实际 QPS 接近目标且偏差可解释);
  5. - 批大小(bulk 模式每请求行数 = plan.batchSize);
  6. - 持续时间 / 阶梯配置(rampSteps,缺省固定档);
  7. - 错误数据比例(errorRate 概率缺必填字段,与 N3 partial-reject 同语义);
  8. - 幂等重复(idemReplay:复用同一 idempotencyKey 重发);
  9. - 立即停止(guard.stop 信号,不再调度新请求,等待在途收尾);
  10. - 达到连续错误/超时/429 阈值自动停止(AutoStopTracker);
  11. - 每轮唯一 runId;行构造剔除租户字段(§20 安全限制)。
  12. """
  13. from __future__ import annotations
  14. import random
  15. import sys
  16. import threading
  17. import time
  18. from concurrent.futures import ThreadPoolExecutor
  19. from pathlib import Path
  20. _SIM_ROOT = Path(__file__).resolve().parents[1]
  21. if str(_SIM_ROOT) not in sys.path:
  22. sys.path.insert(0, str(_SIM_ROOT))
  23. from catalog import sample_adapters as sa # noqa: E402
  24. from load import controls # noqa: E402
  25. from load.metrics import LoadMetrics # noqa: E402
  26. class _RateLimiter:
  27. """固定 QPS 节流器(槽位法:请求间隔 = 1/QPS)。
  28. 近似实现,实际 QPS 受网络与并发影响,偏差在报告中可解释(§20 验收)。
  29. """
  30. def __init__(self, qps: float):
  31. self.interval = 1.0 / qps if qps > 0 else 0.0
  32. self._next = 0.0
  33. self._lock = threading.Lock()
  34. def wait(self) -> None:
  35. if self.interval <= 0:
  36. return
  37. with self._lock:
  38. now = time.monotonic()
  39. slot = self._next if now < self._next else now
  40. self._next = slot + self.interval
  41. delay = slot - now
  42. if delay > 0:
  43. time.sleep(delay)
  44. class P3Runner(threading.Thread):
  45. """后台线程运行方式丙压测;结束后把 LoadGuard 收尾(含无残留断言)。"""
  46. def __init__(self, client, entity: str, template_rows: list[dict], plan: dict,
  47. guard: controls.LoadGuard, metrics: LoadMetrics, *,
  48. contract_version: str | None = None):
  49. super().__init__(name=f"p3-runner-{plan.get('runId', 'x')}", daemon=True)
  50. self.client = client
  51. self.entity = entity
  52. self.template_rows = [dict(r) for r in template_rows]
  53. self.plan = plan
  54. self.guard = guard
  55. self.metrics = metrics
  56. self.contract_version = contract_version
  57. self.tracker = controls.AutoStopTracker(
  58. int(plan.get("maxConsecutiveErrors") or 5),
  59. int(plan.get("maxTimeouts") or 5),
  60. int(plan.get("maxRateLimited") or 5))
  61. self.mode = plan.get("mode") or "push" # push | bulk
  62. self.batch_size = max(1, int(plan.get("batchSize") or 1))
  63. self.error_rate = float(plan.get("errorRate") or 0.0)
  64. self.idem_replay = bool(plan.get("idemReplay"))
  65. self._first_required = self._resolve_first_required_field()
  66. self._replay_idem = f"LOAD-REPLAY-{random.randrange(1 << 32):08x}"
  67. # -- 行构造 --------------------------------------------------------------
  68. def _resolve_first_required_field(self) -> str | None:
  69. spec = sa.INBOUND_SPECS.get(self.entity) or []
  70. for field, required, _ in spec:
  71. if required:
  72. return field
  73. return None
  74. def _build_row(self, seq: int) -> dict:
  75. """固定/随机样例 + 序号唯一化 + 租户字段剔除。"""
  76. row = dict(random.choice(self.template_rows)) if self.plan.get("sampleMode") == "random" \
  77. else dict(self.template_rows[seq % len(self.template_rows)])
  78. row = controls.strip_tenant_fields(row)
  79. for key in ("bizKey", "Id"):
  80. if key in row:
  81. row[key] = f"{row[key]}#{self.guard.run_id}-{seq}"
  82. row["sourceUpdatedAt"] = sa.now_iso()
  83. return row
  84. def _one_request(self, seq: int) -> None:
  85. rows = [self._build_row(seq)]
  86. bad = self.error_rate > 0 and random.random() < self.error_rate
  87. if bad and self._first_required:
  88. # 部分错误数据:缺必填字段(同 N3 partial-reject 语义)
  89. rows[0].pop(self._first_required, None)
  90. started = time.monotonic()
  91. kwargs: dict = {}
  92. if self.idem_replay:
  93. kwargs["idem"] = self._replay_idem
  94. try:
  95. if self.mode == "bulk":
  96. if self.batch_size > 1:
  97. rows.extend(self._build_row(seq * 1000 + i) for i in range(1, self.batch_size))
  98. lines = [{"idempotencyKey": f"{self.guard.run_id}-L{seq}-{i}",
  99. "data": {"list": [r]}} for i, r in enumerate(rows)]
  100. status, _, _ = self.client.op_bulk(self.entity, lines,
  101. contract_version=self.contract_version)
  102. else:
  103. envelope = sa.inbound_envelope(rows)
  104. status, _, _ = self.client.op_push(self.entity, envelope,
  105. contract_version=self.contract_version, **kwargs)
  106. latency = time.monotonic() - started
  107. self.metrics.record(status, latency, rows=len(rows))
  108. if self.tracker.record(status):
  109. self.guard.request_stop(f"自动停止:{self.tracker.tripped_by} 达到阈值")
  110. except Exception as ex: # noqa: BLE001 —— 压测收集异常而非中断
  111. self.metrics.record(0, time.monotonic() - started, rows=len(rows))
  112. self.guard.stop_reason = self.guard.stop_reason or f"worker 异常:{type(ex).__name__}"
  113. if self.tracker.record(0):
  114. self.guard.request_stop(f"自动停止:{self.tracker.tripped_by} 达到阈值")
  115. # -- 阶梯 ----------------------------------------------------------------
  116. def _ramp_steps(self) -> list[dict]:
  117. steps = self.plan.get("rampSteps") or []
  118. if steps:
  119. return steps
  120. return [{
  121. "duration": float(self.plan.get("maxDurationSeconds") or 10),
  122. "qps": float(self.plan.get("maxQps") or 1),
  123. "concurrency": int(self.plan.get("maxConcurrency") or 1),
  124. }]
  125. def _run_step(self, step: dict) -> None:
  126. duration = max(0.5, float(step.get("duration") or 1))
  127. qps = max(0.0, float(step.get("qps") or 0))
  128. concurrency = max(1, int(step.get("concurrency") or 1))
  129. limiter = _RateLimiter(qps)
  130. deadline = time.monotonic() + duration
  131. futures = []
  132. seq = 0
  133. with ThreadPoolExecutor(max_workers=concurrency) as pool:
  134. while time.monotonic() < deadline and not self.guard.stop.is_set():
  135. limiter.wait()
  136. if self.guard.stop.is_set():
  137. break
  138. seq += 1
  139. futures.append(pool.submit(self._one_request, seq))
  140. if len(futures) >= concurrency * 8:
  141. futures = [f for f in futures if not f.done()]
  142. # 停止后:取消排队未启动的任务,只等待真正在途的请求收尾(§20)
  143. pool.shutdown(wait=True, cancel_futures=True)
  144. def run(self) -> None:
  145. self.guard.register_worker(self)
  146. warmup = max(0, int(self.plan.get("warmupSeconds") or 0))
  147. if warmup and not self.guard.stop.is_set():
  148. # §17 预热时间:等待环境稳定后再发压(可被立即停止打断)
  149. self.guard.stop.wait(timeout=warmup)
  150. self.metrics.mark_start()
  151. try:
  152. for step in self._ramp_steps():
  153. if self.guard.stop.is_set():
  154. break
  155. self._run_step(step)
  156. finally:
  157. self.metrics.mark_finish()
  158. result = self.guard.finish()
  159. if not result.get("ok"):
  160. # 在途线程未收尾:记录到 stop_reason 供报告如实呈现
  161. self.guard.stop_reason = (self.guard.stop_reason or "") + \
  162. f"(残留线程:{result.get('residualThreads')})"