| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- """第三阶段压力指标、P1 批量生成、P2 压力模式测试(任务书 §17/§18/§19)。
- 全部进程内验证(纯函数/TestClient),不依赖 Ai-DOP 在线与真实 MySQL。
- """
- from __future__ import annotations
- import threading
- import time
- from concurrent.futures import ThreadPoolExecutor
- import pytest
- fastapi_testclient = pytest.importorskip("fastapi.testclient")
- TestClient = fastapi_testclient.TestClient
- from load import metrics as lm # noqa: E402
- from providers import mock_api # noqa: E402
- from providers import mock_db # noqa: E402
- # ---------------------------------------------------------------------------
- # percentile(§17 P50/P95/P99 计算)
- # ---------------------------------------------------------------------------
- def test_percentile_linear_interpolation():
- assert lm.percentile([1.0, 2.0, 3.0, 4.0], 50) == 2.5
- assert lm.percentile([1.0, 2.0, 3.0, 4.0], 25) == 1.75
- def test_percentile_boundaries_and_empty():
- assert lm.percentile([1.0, 2.0, 3.0], 0) == 1.0
- assert lm.percentile([1.0, 2.0, 3.0], 100) == 3.0
- assert lm.percentile([], 50) == 0.0
- assert lm.percentile([5.0], 95) == 5.0
- def test_percentile_p95_p99_of_100_values():
- vals = [float(i) for i in range(100)]
- assert lm.percentile(vals, 95) == 94.05
- assert lm.percentile(vals, 99) == 98.01
- # ---------------------------------------------------------------------------
- # LoadMetrics(§17 指标采集与快照)
- # ---------------------------------------------------------------------------
- def test_record_classifies_statuses_and_snapshot():
- m = lm.LoadMetrics()
- m.mark_start()
- m.record(200, 0.01, rows=1)
- m.record(201, 0.02)
- m.record(202, 0.03, batches=2)
- m.record(500, 0.04)
- m.record(0, 0.05)
- m.record(429, 0.06)
- m.mark_finish()
- s = m.snapshot()
- assert s["totalRequests"] == 6
- assert s["success"] == 3 and s["failed"] == 3
- assert s["successRate"] == 0.5
- assert s["timeouts"] == 1 and s["rateLimited"] == 1
- assert s["httpErrors"] == {500: 1}
- assert s["totalBatches"] == 7
- assert s["totalRows"] == 1
- assert s["avgMs"] == 35.0
- assert s["maxMs"] == 60.0
- assert s["p50Ms"] == 35.0
- assert s["firstFailureAt"] is not None
- assert s["startedAt"] is not None and s["finishedAt"] is not None
- def test_snapshot_contains_all_metric_fields():
- m = lm.LoadMetrics()
- m.mark_start()
- m.record(200, 0.1, rows=2)
- m.mark_finish()
- s = m.snapshot()
- for field, _ in lm.METRIC_FIELDS:
- assert field in s, f"missing metric field: {field}"
- assert s["totalRequests"] == 1 and s["successRate"] == 1.0
- def test_actual_qps_empty_and_completion_window_pruning():
- m = lm.LoadMetrics(window_seconds=10)
- assert m.actual_qps() == 0.0
- now = time.monotonic()
- with m._lock:
- m._completion_times[:] = [now - 100, now - 5, now - 0.5]
- pruned = m._pruned_completion_times(now)
- assert pruned == [now - 5, now - 0.5]
- def test_row_throughput_uses_wall_clock_span():
- m = lm.LoadMetrics()
- m.mark_start()
- m.record(200, 0.001, rows=10)
- m.mark_finish()
- with m._lock:
- m.started_at = "2026-01-01T10:00:00"
- m.finished_at = "2026-01-01T10:00:10"
- assert m.row_throughput() == 1.0
- def test_snapshot_freezes_qps_after_finish():
- # 停止后快照的 QPS 必须定格:滚动窗口分母不再随时间变大(用户报告“停止后 QPS 还在变”)
- m = lm.LoadMetrics(window_seconds=10)
- m.mark_start()
- m.record(200, 0.01)
- m.record(200, 0.01)
- m.mark_finish()
- s1 = m.snapshot()
- time.sleep(0.15)
- s2 = m.snapshot()
- assert s1["actualQps"] > 0
- assert s1["actualQps"] == s2["actualQps"]
- assert s1["rowThroughput"] == s2["rowThroughput"]
- def test_mark_start_resets_previous_round_metrics():
- # 单例跨轮复用:新一轮 mark_start 必须清空上一轮累计(防止多轮指标叠加)
- m = lm.LoadMetrics()
- m.mark_start()
- m.record(200, 0.01, rows=3)
- m.mark_finish()
- m.mark_start()
- s = m.snapshot()
- assert s["totalRequests"] == 0
- assert s["totalRows"] == 0
- assert s["startedAt"] is not None
- assert s["finishedAt"] is None
- def test_metrics_thread_safe_concurrent_records():
- m = lm.LoadMetrics()
- def hit(i: int) -> None:
- m.record(200 if i % 3 else 500, 0.001)
- with ThreadPoolExecutor(max_workers=8) as pool:
- list(pool.map(hit, range(300)))
- s = m.snapshot()
- assert s["totalRequests"] == 300
- assert s["success"] == 200 and s["failed"] == 100
- assert s["httpErrors"] == {500: 100}
- # ---------------------------------------------------------------------------
- # P1 批量生成 generate_row_blocks(§18:档位/增量比例/字段宽度/一键停止)
- # ---------------------------------------------------------------------------
- def test_p1_row_blocks_chunked_by_batch_size():
- rows = [{"bizKey": "B", "sourceUpdatedAt": "2026-01-01T00:00:00"}]
- blocks = list(mock_db.generate_row_blocks(rows, 2500, batch_size=1000))
- assert [len(b) for b in blocks] == [1000, 1000, 500]
- def test_p1_row_blocks_bizkey_unique_with_full_increment():
- rows = [{"bizKey": "B"}]
- blocks = list(mock_db.generate_row_blocks(rows, 50, increment_ratio=1.0, batch_size=10))
- keys = [r["bizKey"] for b in blocks for r in b]
- assert len(set(keys)) == 50
- assert all(k.startswith("B#LOAD-") for k in keys)
- def test_p1_row_blocks_increment_ratio_reuses_template_keys():
- rows = [{"bizKey": "B"}]
- blocks = list(mock_db.generate_row_blocks(rows, 10, increment_ratio=0.5, batch_size=100))
- keys = [r["bizKey"] for b in blocks for r in b]
- assert keys[:5] == [f"B#LOAD-{i}" for i in range(5)]
- assert keys[5:] == ["B"] * 5
- def test_p1_row_blocks_pads_text_fields():
- rows = [{"bizKey": "B", "note": "hi"}]
- (block,) = list(mock_db.generate_row_blocks(rows, 1, field_width=8, batch_size=10))
- row = block[0]
- assert row["note"] == "hixxxxxx"
- assert len(row["bizKey"]) >= 8
- def test_p1_row_blocks_increment_column_advances():
- rows = [{"bizKey": "B", "sourceUpdatedAt": "2026-01-01T00:00:00"}]
- blocks = list(mock_db.generate_row_blocks(rows, 100, batch_size=30))
- vals = [r["sourceUpdatedAt"] for b in blocks for r in b]
- assert len(set(vals)) == 100
- def test_p1_row_blocks_stop_event_before_and_midway():
- rows = [{"bizKey": "B"}]
- stop = threading.Event()
- stop.set()
- assert list(mock_db.generate_row_blocks(rows, 100, stop_event=stop)) == []
- stop2 = threading.Event()
- gen = mock_db.generate_row_blocks(rows, 100, batch_size=10, stop_event=stop2)
- first = next(gen)
- stop2.set()
- assert len(first) == 10
- assert list(gen) == []
- # ---------------------------------------------------------------------------
- # P2 压力模式(§19:分页截断/错误比例/限流/统计/恢复)
- # ---------------------------------------------------------------------------
- @pytest.fixture()
- def p2client():
- state = mock_api.MockApiState()
- state.settings.auth_mode = "NONE"
- return TestClient(mock_api.create_app(state)), state
- def _override(c, path: str, n: int) -> None:
- c.post("/__control/overrides",
- json={"path": path, "rows": [{"bizKey": f"K{i}", "v": i} for i in range(n)]})
- def test_p2_page_size_applies_only_in_load_mode(p2client):
- c, _ = p2client
- _override(c, "/api/load-a", 10)
- c.post("/__control/settings", json={"page_size": 3})
- assert len(c.get("/api/load-a").json()["data"]["list"]) == 10 # 未开压力模式不截断
- c.post("/__control/settings", json={"load_mode": True})
- assert len(c.get("/api/load-a").json()["data"]["list"]) == 3
- def test_p2_total_rows_limit_truncates_in_load_mode(p2client):
- c, _ = p2client
- _override(c, "/api/load-b", 10)
- c.post("/__control/settings", json={"load_mode": True, "total_rows_limit": 5})
- assert len(c.get("/api/load-b").json()["data"]["list"]) == 5
- def test_p2_error_rate_injects_500(p2client):
- c, _ = p2client
- _override(c, "/api/load-c", 3)
- c.post("/__control/settings", json={"load_mode": True, "error_rate": 1.0})
- assert c.get("/api/load-c").status_code == 500
- def test_p2_rate_limit_returns_429_beyond_threshold(p2client):
- c, _ = p2client
- _override(c, "/api/load-d", 3)
- c.post("/__control/settings", json={"load_mode": True, "rate_limit": 1})
- assert c.get("/api/load-d").status_code == 200
- assert c.get("/api/load-d").status_code == 429
- def test_p2_load_stats_tracks_requests_and_errors(p2client):
- c, _ = p2client
- _override(c, "/api/load-e", 3)
- c.post("/__control/settings", json={"load_mode": True, "error_rate": 1.0})
- c.get("/api/load-e")
- stats = c.get("/__control/load-stats").json()
- assert stats["loadMode"] is True
- assert stats["stats"]["requests"] == 1
- # JSON 序列化后状态码键为字符串
- assert stats["stats"]["statusCounts"] == {"500": 1}
- assert set(stats["stats"]) >= {
- "requests", "active", "peakConcurrency", "qpsWindow",
- "avgMs", "maxMs", "statusCounts", "rateLimited"}
- def test_p2_reset_clears_pressure_settings(p2client):
- c, _ = p2client
- c.post("/__control/settings", json={
- "load_mode": True, "page_size": 2, "total_rows_limit": 4,
- "random_delay": 1.5, "error_rate": 0.5, "rate_limit": 10})
- c.post("/__control/settings", json={"reset": True})
- s = c.get("/__control/settings").json()
- assert s["loadMode"] is False
- assert s["pageSize"] == 0 and s["totalRowsLimit"] == 0
- assert s["randomDelay"] == 0.0 and s["errorRate"] == 0.0 and s["rateLimit"] == 0.0
|