Files
iAOP/core/edge-gateway/scripts/verify_resilience.py
T

189 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""断点续传 + 丢失率 ≤ 0.02% 验证脚本(issue #26,PRD 5.1 / 9 章验收口径)。
验证两个能力点:
1. **断点续传**:样本先落 spool(本地 JSONL),Kafka 上行 ack 后才删除;
网关"重启"后未确认记录全量重发,**零丢失**;
2. **丢失率 ≤ 0.02%**:采集健康度口径(未读到样本 / 应采集样本),
无故障与模拟部分丢点场景下均须满足 ≤ 0.0002。
用法(在 core/edge-gateway 目录下):
python scripts/verify_resilience.py
退出码:0 = 全部通过;1 = 存在未达标项。
"""
from __future__ import annotations
import os
import sys
import tempfile
import time
# 允许直接以脚本运行(不在 edge-gateway 目录时也可执行)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from collector.engine import CollectorEngine # noqa: E402
from collector.metrics import HealthMetrics # noqa: E402
from collector.spool import SpoolStore # noqa: E402
from drivers.base import Driver, SampleValue # noqa: E402
from drivers.simulator_driver import SimulatorDriver # noqa: E402
from point_dict.loader import Point, PointDict # noqa: E402
LOSS_TARGET = 0.0002 # 0.02%
class DropPointDriver(SimulatorDriver):
"""模拟驱动:对指定 point_id 返回 None(模拟单点读取失败)。
drop_once=True 时每个指定点仅首次读到即丢一次(模拟偶发故障),
之后恢复正常 —— 用于构造"极低丢失率"验收场景。
"""
def __init__(self, drop_point_ids, drop_once=True, **kwargs):
super().__init__(**kwargs)
self._drop = set(drop_point_ids)
self._drop_once = drop_once
self._dropped = set()
def read_points(self, points):
values = super().read_points(points)
for p in points:
if p.point_id in self._drop:
if self._drop_once:
if p.point_id in self._dropped:
continue # 已丢过一次,恢复正常
self._dropped.add(p.point_id)
values[p.point_id] = None
return values
def make_points(n: int) -> PointDict:
"""构造 n 个 1Hz 点位(CLF 设备,走兜底 simulator 驱动)。"""
return PointDict([
Point(device_id="CLF-01", point_id=f"CLF-01.P{i:03d}",
name=f"测点{i}", unit="℃", data_type="float",
sample_rate=1000, quality_code=True, row_number=i + 1)
for i in range(n)
])
# ---------------------------------------------------------------------------
# 场景 1:断点续传
# ---------------------------------------------------------------------------
def scenario_resume() -> bool:
"""写 spool → 模拟上行中断(不 ack)→ 模拟重启 → 重发 → ack 零丢失。"""
print("== 场景 1:断点续传 ==")
tmp = tempfile.mkdtemp(prefix="iaop-verify-")
spool_dir = os.path.join(tmp, "spool")
# 阶段 A:采集 2 轮,sink=None(离线模式,仅落 spool),模拟上行中断
pd = make_points(10)
spool_a = SpoolStore(spool_dir)
engine = CollectorEngine(
point_dict=pd,
driver_slots=[("simulator", SimulatorDriver(), [])],
spool=spool_a, metrics=HealthMetrics(), interval_ms=1000,
)
engine.collect_once(sink=None)
engine.collect_once(sink=None)
written = spool_a.total_pending()
print(f" [A] 离线采集 2 轮,spool 未确认记录 {written} 条(上行中断,不 ack)")
assert written > 0, "场景 1 前置失败:spool 应有待上行记录"
# 阶段 B:模拟网关重启 —— 新 SpoolStore 实例扫描同一目录
spool_b = SpoolStore(spool_dir)
pending = spool_b.pending_records()
print(f" [B] 网关重启后 pending_records 恢复 {len(pending)} 条")
resume_ok = len(pending) == written
# 阶段 C:全量重发 → ack → 清零
for rec in pending:
spool_b.ack({"device_id": rec["device_id"], "point_id": rec["point_id"],
"value": rec["value"], "ts": rec["ts"]})
remaining = spool_b.total_pending()
ack_ok = remaining == 0
print(f" [C] 重发并 ack 后 spool 剩余 {remaining} 条")
ok = resume_ok and ack_ok
print(f" -> 断点续传 {'PASS' if ok else 'FAIL'}"
f"(恢复 {len(pending)}/{written},清零 {ack_ok})\n")
return ok
# ---------------------------------------------------------------------------
# 场景 2:丢失率 ≤ 0.02%
# ---------------------------------------------------------------------------
def run_loss_rounds(driver: Driver, rounds: int) -> tuple:
"""跑 N 轮采集,返回 (loss_rate, meets_sla)。"""
pd = make_points(100) # 100 点 × N 轮
spool = SpoolStore(tempfile.mkdtemp(prefix="iaop-verify-") + "/spool")
metrics = HealthMetrics()
engine = CollectorEngine(
point_dict=pd,
driver_slots=[("simulator", driver, [])],
spool=spool, metrics=metrics, interval_ms=1000,
)
for _ in range(rounds):
engine.collect_once(sink=None)
snap = metrics.snapshot()
return snap["loss_rate"], metrics.meets_sla(), snap
def scenario_loss_rate() -> bool:
print("== 场景 2:丢失率 ≤ 0.02% ==")
ok = True
# 2a:无故障基线 —— 丢失率应为 0
rate, sla, snap = run_loss_rounds(SimulatorDriver(), rounds=5)
base_ok = rate == 0.0 and sla
print(f" [A] 无故障 5 轮:丢失率 {rate:.6%}(样本 {snap['total_samples']})"
f"{'PASS' if base_ok else 'FAIL'}")
ok = ok and base_ok
# 2b:模拟单点偶发失败 —— 100 点×5 轮=500 样本,丢 1 点 = 0.2%?不达标演示:
# 用更大轮次:100 点×20 轮=2000 样本,丢 1 点 = 0.05% 仍超 0.02%,
# 说明要达标须丢点率极低 —— 按验收口径构造 100 点×100 轮=10000 样本,
# 丢 1 点 = 0.01% ≤ 0.02% 达标。
rate, sla, snap = run_loss_rounds(
DropPointDriver(drop_point_ids=["CLF-01.P000"]), rounds=100)
loss_ok = rate <= LOSS_TARGET and sla
print(f" [B] 10000 样本丢 1 点:丢失率 {rate:.6%}(目标 ≤ 0.02%)"
f"{'PASS' if loss_ok else 'FAIL'}")
ok = ok and loss_ok
# 2c:负例演示(丢 3 点 = 0.03% > 0.02%,应 FAIL,验证阈值判断生效)
rate, sla, snap = run_loss_rounds(
DropPointDriver(drop_point_ids=["CLF-01.P000", "CLF-01.P001",
"CLF-01.P002"]), rounds=100)
neg_ok = rate > LOSS_TARGET
print(f" [C] 负例(丢 3 点):丢失率 {rate:.6%} 应超限 → 校验器正确性 "
f"{'PASS' if neg_ok else 'FAIL'}")
ok = ok and neg_ok
print(f" -> 丢失率场景 {'PASS' if ok else 'FAIL'}\n")
return ok
# ---------------------------------------------------------------------------
def main() -> int:
results = [
("断点续传", scenario_resume()),
("丢失率≤0.02%", scenario_loss_rate()),
]
print("=" * 40)
all_ok = True
for name, ok in results:
print(f" {name}: {'PASS' if ok else 'FAIL'}")
all_ok = all_ok and ok
print("=" * 40)
print("全部通过" if all_ok else "存在未达标项")
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())