| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- """方式丙压测执行器(任务书 §20):模拟器是负载发起方。
- 能力映射:
- - 并发 worker(ThreadPoolExecutor,plan.maxConcurrency);
- - QPS 限制(槽位法节流器,实际 QPS 接近目标且偏差可解释);
- - 批大小(bulk 模式每请求行数 = plan.batchSize);
- - 持续时间 / 阶梯配置(rampSteps,缺省固定档);
- - 错误数据比例(errorRate 概率缺必填字段,与 N3 partial-reject 同语义);
- - 幂等重复(idemReplay:复用同一 idempotencyKey 重发);
- - 立即停止(guard.stop 信号,不再调度新请求,等待在途收尾);
- - 达到连续错误/超时/429 阈值自动停止(AutoStopTracker);
- - 每轮唯一 runId;行构造剔除租户字段(§20 安全限制)。
- """
- from __future__ import annotations
- import random
- import sys
- import threading
- import time
- from concurrent.futures import ThreadPoolExecutor
- from pathlib import Path
- _SIM_ROOT = Path(__file__).resolve().parents[1]
- if str(_SIM_ROOT) not in sys.path:
- sys.path.insert(0, str(_SIM_ROOT))
- from catalog import sample_adapters as sa # noqa: E402
- from load import controls # noqa: E402
- from load.metrics import LoadMetrics # noqa: E402
- class _RateLimiter:
- """固定 QPS 节流器(槽位法:请求间隔 = 1/QPS)。
- 近似实现,实际 QPS 受网络与并发影响,偏差在报告中可解释(§20 验收)。
- """
- def __init__(self, qps: float):
- self.interval = 1.0 / qps if qps > 0 else 0.0
- self._next = 0.0
- self._lock = threading.Lock()
- def wait(self) -> None:
- if self.interval <= 0:
- return
- with self._lock:
- now = time.monotonic()
- slot = self._next if now < self._next else now
- self._next = slot + self.interval
- delay = slot - now
- if delay > 0:
- time.sleep(delay)
- class P3Runner(threading.Thread):
- """后台线程运行方式丙压测;结束后把 LoadGuard 收尾(含无残留断言)。"""
- def __init__(self, client, entity: str, template_rows: list[dict], plan: dict,
- guard: controls.LoadGuard, metrics: LoadMetrics, *,
- contract_version: str | None = None):
- super().__init__(name=f"p3-runner-{plan.get('runId', 'x')}", daemon=True)
- self.client = client
- self.entity = entity
- self.template_rows = [dict(r) for r in template_rows]
- self.plan = plan
- self.guard = guard
- self.metrics = metrics
- self.contract_version = contract_version
- self.tracker = controls.AutoStopTracker(
- int(plan.get("maxConsecutiveErrors") or 5),
- int(plan.get("maxTimeouts") or 5),
- int(plan.get("maxRateLimited") or 5))
- self.mode = plan.get("mode") or "push" # push | bulk
- self.batch_size = max(1, int(plan.get("batchSize") or 1))
- self.error_rate = float(plan.get("errorRate") or 0.0)
- self.idem_replay = bool(plan.get("idemReplay"))
- self._first_required = self._resolve_first_required_field()
- self._replay_idem = f"LOAD-REPLAY-{random.randrange(1 << 32):08x}"
- # -- 行构造 --------------------------------------------------------------
- def _resolve_first_required_field(self) -> str | None:
- spec = sa.INBOUND_SPECS.get(self.entity) or []
- for field, required, _ in spec:
- if required:
- return field
- return None
- def _build_row(self, seq: int) -> dict:
- """固定/随机样例 + 序号唯一化 + 租户字段剔除。"""
- row = dict(random.choice(self.template_rows)) if self.plan.get("sampleMode") == "random" \
- else dict(self.template_rows[seq % len(self.template_rows)])
- row = controls.strip_tenant_fields(row)
- for key in ("bizKey", "Id"):
- if key in row:
- row[key] = f"{row[key]}#{self.guard.run_id}-{seq}"
- row["sourceUpdatedAt"] = sa.now_iso()
- return row
- def _one_request(self, seq: int) -> None:
- rows = [self._build_row(seq)]
- bad = self.error_rate > 0 and random.random() < self.error_rate
- if bad and self._first_required:
- # 部分错误数据:缺必填字段(同 N3 partial-reject 语义)
- rows[0].pop(self._first_required, None)
- started = time.monotonic()
- kwargs: dict = {}
- if self.idem_replay:
- kwargs["idem"] = self._replay_idem
- try:
- if self.mode == "bulk":
- if self.batch_size > 1:
- rows.extend(self._build_row(seq * 1000 + i) for i in range(1, self.batch_size))
- lines = [{"idempotencyKey": f"{self.guard.run_id}-L{seq}-{i}",
- "data": {"list": [r]}} for i, r in enumerate(rows)]
- status, _, _ = self.client.op_bulk(self.entity, lines,
- contract_version=self.contract_version)
- else:
- envelope = sa.inbound_envelope(rows)
- status, _, _ = self.client.op_push(self.entity, envelope,
- contract_version=self.contract_version, **kwargs)
- latency = time.monotonic() - started
- self.metrics.record(status, latency, rows=len(rows))
- if self.tracker.record(status):
- self.guard.request_stop(f"自动停止:{self.tracker.tripped_by} 达到阈值")
- except Exception as ex: # noqa: BLE001 —— 压测收集异常而非中断
- self.metrics.record(0, time.monotonic() - started, rows=len(rows))
- self.guard.stop_reason = self.guard.stop_reason or f"worker 异常:{type(ex).__name__}"
- if self.tracker.record(0):
- self.guard.request_stop(f"自动停止:{self.tracker.tripped_by} 达到阈值")
- # -- 阶梯 ----------------------------------------------------------------
- def _ramp_steps(self) -> list[dict]:
- steps = self.plan.get("rampSteps") or []
- if steps:
- return steps
- return [{
- "duration": float(self.plan.get("maxDurationSeconds") or 10),
- "qps": float(self.plan.get("maxQps") or 1),
- "concurrency": int(self.plan.get("maxConcurrency") or 1),
- }]
- def _run_step(self, step: dict) -> None:
- duration = max(0.5, float(step.get("duration") or 1))
- qps = max(0.0, float(step.get("qps") or 0))
- concurrency = max(1, int(step.get("concurrency") or 1))
- limiter = _RateLimiter(qps)
- deadline = time.monotonic() + duration
- futures = []
- seq = 0
- with ThreadPoolExecutor(max_workers=concurrency) as pool:
- while time.monotonic() < deadline and not self.guard.stop.is_set():
- limiter.wait()
- if self.guard.stop.is_set():
- break
- seq += 1
- futures.append(pool.submit(self._one_request, seq))
- if len(futures) >= concurrency * 8:
- futures = [f for f in futures if not f.done()]
- # 停止后:取消排队未启动的任务,只等待真正在途的请求收尾(§20)
- pool.shutdown(wait=True, cancel_futures=True)
- def run(self) -> None:
- self.guard.register_worker(self)
- warmup = max(0, int(self.plan.get("warmupSeconds") or 0))
- if warmup and not self.guard.stop.is_set():
- # §17 预热时间:等待环境稳定后再发压(可被立即停止打断)
- self.guard.stop.wait(timeout=warmup)
- self.metrics.mark_start()
- try:
- for step in self._ramp_steps():
- if self.guard.stop.is_set():
- break
- self._run_step(step)
- finally:
- self.metrics.mark_finish()
- result = self.guard.finish()
- if not result.get("ok"):
- # 在途线程未收尾:记录到 stop_reason 供报告如实呈现
- self.guard.stop_reason = (self.guard.stop_reason or "") + \
- f"(残留线程:{result.get('residualThreads')})"
|