| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- """第三阶段压力测试安全控制与 P3 执行器测试(任务书 §16/§20/§21 验收表)。"""
- from __future__ import annotations
- import threading
- import time
- import pytest
- from catalog import sample_adapters as sa
- from load import controls
- from load.metrics import LoadMetrics
- from load.runner_p3 import P3Runner, _RateLimiter
- def _valid_plan(**over) -> dict:
- plan = {
- "channel": "API_INBOUND",
- "environment": "dev", "tenant": "T1", "owner": "qa", "timeWindow": "t",
- "maxDataRows": 1000, "maxConcurrency": 4, "maxQps": 10, "maxDurationSeconds": 5,
- "stopThreshold": "连续 5 次错误/超时/429 自动停止", "cleanupPlan": "clean",
- "confirmed": True,
- }
- plan.update(over)
- return plan
- # ---------------------------------------------------------------------------
- # validate_plan(§16 十项必填 / 上限 / 二次确认)
- # ---------------------------------------------------------------------------
- def test_validate_plan_accepts_complete_plan():
- assert controls.validate_plan(_valid_plan()) == []
- def test_validate_plan_rejects_missing_required_fields():
- errors = controls.validate_plan({"channel": "API_INBOUND"})
- assert len(errors) >= 10
- assert any("environment" in e for e in errors)
- def test_validate_plan_requires_owner_confirmation():
- errors = controls.validate_plan(_valid_plan(confirmed=False))
- assert any("负责人确认" in e for e in errors)
- def test_validate_plan_blocks_over_limit_without_escalation():
- errors = controls.validate_plan(_valid_plan(maxQps=999))
- assert any("超过保守上限" in e for e in errors)
- def test_validate_plan_allows_escalation_but_blocks_hard_limit():
- ok = controls.validate_plan(_valid_plan(maxQps=60, escalationConfirm=True))
- assert ok == []
- errors = controls.validate_plan(_valid_plan(maxQps=10000, escalationConfirm=True))
- assert any("硬上限" in e for e in errors)
- def test_validate_plan_rejects_bad_channel_and_error_rate_range():
- errors = controls.validate_plan(_valid_plan(channel="NOPE"))
- assert any("channel" in e for e in errors)
- errors = controls.validate_plan(_valid_plan(errorRate=1.5))
- assert any("errorRate" in e for e in errors)
- # ---------------------------------------------------------------------------
- # 租户字段剔除(§20:请求体不得携带任意 tenantId)
- # ---------------------------------------------------------------------------
- def test_strip_tenant_fields_removes_tenant_keys():
- row = {"ItemNum": "A", "tenantId": "999", "tenant_id": "888",
- "Domain": "SIM", "bizKey": "K1"}
- stripped = controls.strip_tenant_fields(dict(row))
- assert "tenantId" not in stripped and "tenant_id" not in stripped
- assert stripped["Domain"] == "SIM" and stripped["bizKey"] == "K1"
- # ---------------------------------------------------------------------------
- # AutoStopTracker(§20 自动停止阈值)
- # ---------------------------------------------------------------------------
- def test_auto_stop_tracker_consecutive_errors():
- tracker = controls.AutoStopTracker(consecutive_errors=3)
- assert not tracker.record(202)
- assert not tracker.record(500)
- assert not tracker.record(500)
- assert tracker.record(500)
- assert tracker.tripped_by == "consecutiveErrors"
- def test_auto_stop_tracker_timeouts_and_429():
- tracker = controls.AutoStopTracker(timeouts=2, rate_limited=2)
- tracker.record(0)
- assert tracker.record(0) and tracker.tripped_by == "timeouts"
- tracker2 = controls.AutoStopTracker(timeouts=2, rate_limited=2)
- tracker2.record(429)
- assert tracker2.record(429) and tracker2.tripped_by == "rateLimited"
- def test_auto_stop_tracker_success_resets_consecutive():
- tracker = controls.AutoStopTracker(consecutive_errors=3)
- tracker.record(500)
- tracker.record(500)
- tracker.record(202)
- assert not tracker.record(500)
- assert not tracker.record(500)
- # ---------------------------------------------------------------------------
- # LoadGuard 生命周期(默认关闭 / 立即停止 / 无残留)
- # ---------------------------------------------------------------------------
- def test_guard_default_state_is_idle_and_blocks_start():
- guard = controls.LoadGuard()
- assert guard.state == "idle"
- with pytest.raises(controls.ValidationError):
- guard.start()
- assert guard.snapshot()["noResidual"] is True
- def test_guard_lifecycle_ready_running_stopping_stopped():
- guard = controls.LoadGuard()
- assert guard.submit_plan(_valid_plan())["ok"]
- assert guard.state == "ready"
- run_id = guard.start()
- assert guard.state == "running" and run_id.startswith("LOAD-")
- assert guard.request_stop("manual")["state"] == "stopping"
- result = guard.finish()
- assert result["ok"] and result["residualThreads"] == []
- assert guard.state == "stopped"
- def test_guard_finish_reports_residual_worker():
- guard = controls.LoadGuard()
- guard.submit_plan(_valid_plan())
- guard.start()
- worker = threading.Thread(target=lambda: time.sleep(0.2), daemon=True)
- worker.start()
- guard.register_worker(worker)
- guard.request_stop()
- result = guard.finish()
- assert not result["ok"] and "residualThreads" in result
- worker.join(timeout=2)
- def test_guard_finish_excludes_current_thread_from_residual():
- # P1/P3 执行器线程注册自身为 worker 并在自身线程内 finish(),
- # 不得把当前线程误报为残留(回归:曾导致每次收尾都报 residualThreads)
- guard = controls.LoadGuard()
- guard.submit_plan(_valid_plan())
- guard.start()
- result = {}
- def _finish_in_worker():
- guard.register_worker(threading.current_thread())
- guard.request_stop()
- result.update(guard.finish())
- worker = threading.Thread(target=_finish_in_worker, daemon=True)
- worker.start()
- worker.join(timeout=5)
- assert result["ok"] is True and result["residualThreads"] == []
- assert guard.state == "stopped"
- # ---------------------------------------------------------------------------
- # P3 执行器(固定 QPS 短跑 / 立即停止无残留 / 自动停止 / 错误比例注入)
- # ---------------------------------------------------------------------------
- class _FakeInboundClient:
- """进程内假客户端:捕获调用并返回固定状态,不触网。"""
- def __init__(self, status: int = 202):
- self.status = status
- self.calls: list[tuple] = []
- def op_push(self, entity, envelope, **kwargs):
- self.calls.append(("push", entity, envelope, kwargs))
- return self.status, {"ok": True}, {}
- def op_bulk(self, entity, lines, **kwargs):
- self.calls.append(("bulk", entity, lines, kwargs))
- return self.status, {"ok": True}, {}
- def _p3_env(plan_over: dict | None = None, client_status: int = 202):
- guard = controls.LoadGuard()
- metrics = LoadMetrics()
- client = _FakeInboundClient(status=client_status)
- obj = next(o for o in sa.load_catalog()["objects"]
- if (o.get("apiInbound") or {}).get("supported"))
- entity = obj["apiInbound"]["entityCode"]
- rows = sa.adapt_inbound_rows(obj, "T-LOAD")["rows"]
- plan = _valid_plan(**{"entityCode": entity, "runId": "T-LOAD"})
- plan.update(plan_over or {})
- return guard, metrics, client, entity, rows, plan
- def test_p3_runner_short_run_records_metrics_and_finishes_clean():
- guard, metrics, client, entity, rows, plan = _p3_env(
- {"maxDurationSeconds": 1, "maxQps": 30, "maxConcurrency": 4,
- "maxConsecutiveErrors": 5, "maxTimeouts": 5, "maxRateLimited": 5})
- guard.submit_plan(plan)
- guard.start()
- runner = P3Runner(client, entity, rows, plan, guard, metrics)
- runner.start()
- runner.join(timeout=10)
- assert not runner.is_alive()
- assert guard.state == "stopped"
- assert guard.snapshot()["noResidual"] is True
- snap = metrics.snapshot()
- assert snap["totalRequests"] > 0
- assert snap["success"] == snap["totalRequests"]
- assert snap["successRate"] == 1.0
- assert snap["p50Ms"] >= 0 and snap["startedAt"] and snap["finishedAt"]
- def test_p3_runner_immediate_stop_leaves_no_residual():
- guard, metrics, client, entity, rows, plan = _p3_env(
- {"maxDurationSeconds": 10, "maxQps": 5, "maxConcurrency": 2})
- guard.submit_plan(plan)
- guard.start()
- runner = P3Runner(client, entity, rows, plan, guard, metrics)
- runner.start()
- time.sleep(0.2)
- guard.request_stop("manual stop")
- runner.join(timeout=10)
- assert guard.state == "stopped"
- assert guard.snapshot()["noResidual"] is True
- assert metrics.snapshot()["totalRequests"] < 100
- def test_p3_runner_stop_cancels_queued_futures():
- # 回归:stop 后只等在途请求,排队未启动的任务必须取消(否则慢请求下收尾拖沓)
- guard, metrics, client, entity, rows, plan = _p3_env(
- {"maxDurationSeconds": 30, "maxQps": 50, "maxConcurrency": 2})
- guard.submit_plan(plan)
- guard.start()
- def slow_push(entity, envelope, **kwargs):
- time.sleep(0.5)
- client.calls.append(("push", entity, envelope, kwargs))
- return 202, {"ok": True}, {}
- client.op_push = slow_push
- runner = P3Runner(client, entity, rows, plan, guard, metrics)
- runner.start()
- time.sleep(1.0)
- guard.request_stop("manual stop")
- started = time.monotonic()
- runner.join(timeout=10)
- elapsed = time.monotonic() - started
- assert not runner.is_alive()
- assert guard.state == "stopped"
- assert elapsed < 5, f"停止收尾过慢:{elapsed:.1f}s(排队任务未被取消?)"
- assert guard.snapshot()["noResidual"] is True
- def test_p3_runner_auto_stops_on_consecutive_errors():
- guard, metrics, client, entity, rows, plan = _p3_env(
- {"maxDurationSeconds": 30, "maxQps": 50, "maxConcurrency": 2,
- "maxConsecutiveErrors": 2},
- client_status=500)
- guard.submit_plan(plan)
- guard.start()
- runner = P3Runner(client, entity, rows, plan, guard, metrics)
- runner.start()
- runner.join(timeout=10)
- assert "自动停止" in (guard.stop_reason or "")
- assert guard.state == "stopped"
- assert metrics.snapshot()["failed"] >= 2
- def test_p3_runner_error_rate_injects_missing_required_field():
- # errorRate 硬上限 0.9;提交计划后改回 1.0 保证每行都缺必填字段(确定性断言注入逻辑)
- guard, metrics, client, entity, rows, plan = _p3_env(
- {"maxDurationSeconds": 1, "maxQps": 10, "maxConcurrency": 2,
- "errorRate": 0.9, "escalationConfirm": True})
- guard.submit_plan(plan)
- plan["errorRate"] = 1.0
- guard.start()
- runner = P3Runner(client, entity, rows, plan, guard, metrics)
- runner.start()
- runner.join(timeout=10)
- assert client.calls, "压测应发出请求"
- first_required = runner._first_required
- for call in client.calls[:3]:
- envelope = call[2]
- assert first_required not in envelope["data"]["list"][0]
- def test_p3_runner_never_injects_tenant_fields():
- guard, metrics, client, entity, rows, plan = _p3_env(
- {"maxDurationSeconds": 1, "maxQps": 10, "maxConcurrency": 2})
- guard.submit_plan(plan)
- guard.start()
- runner = P3Runner(client, entity, rows, plan, guard, metrics)
- runner.start()
- runner.join(timeout=10)
- for call in client.calls:
- row = call[2]["data"]["list"][0]
- assert "tenantId" not in row and "tenant_id" not in row
- def test_rate_limiter_caps_qps_approximately():
- limiter = _RateLimiter(qps=100)
- started = time.monotonic()
- for _ in range(5):
- limiter.wait()
- elapsed = time.monotonic() - started
- # 5 个槽位应至少占 4 个间隔(4*10ms),宽松断言避免 CI 抖动误报
- assert elapsed >= 0.03
|