This commit is contained in:
2026-08-05 08:19:29 +08:00
4 changed files with 374 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# iAOP 可用性 / 容灾演练套件(tests/ha)
> 对应 Issue **#89 [M4] 可用性/容灾演练**,验证 PRD 第 9 章 NFR「可用性 ≥ 99.8%」
> 以及断点续传 / 故障隔离等容灾能力。
## 背景
PRD 第 9 章 SLA 对边缘采集网关提出可用性 ≥ 99.8%、丢失率 ≤ 0.02% 的硬指标。
这两项指标的工程保障来自三个机制:
1. **spool 断点续传**:样本先落本地磁盘,上行确认(ack)后才删除;
网关重启时扫描 spool 目录,未确认记录全部重发(RPO=0)。
2. **驱动故障隔离**:单驱动异常按「本轮整体失败」计入 HealthMetrics,
不抛出、不崩溃,正常轮次继续产出。
3. **上行通道背压**:Kafka 抖动期间样本驻留 spool,仅增加本地缓存占用,
恢复后断点续传,数据不丢不重。
本套件以**可重复的合成数据集 + 故障注入驱动**对上述机制做规模化演练,
全部基于内核可注入接口运行,零外部依赖,CI 可直接执行。
## 演练场景
| 场景 | 演练口径 | 验证目标 |
|------|----------|----------|
| 可用性基线 | 600 点位 30 轮健康采集 | 可用性 ≥ 99.8% / 丢失率 ≤ 0.02% / P99 ≤ 1.8s |
| 驱动故障隔离 | 30 轮中注入 5 轮设备掉线 | 引擎未崩溃;可用性受控下降(1−5/30);健康轮次仍产出样本 |
| 上行抖动恢复 | 上行连续中断 5 轮后恢复 | 中断期零丢失;恢复后断点续传落库 = 采集数(RPO=0) |
| 进程重启容灾 | 采集 8 轮后销毁引擎模拟重启 | 重启后扫描同一 spool,未确认样本全部重发落库(RPO=0) |
## 运行
在**仓库根目录**执行(`_bootstrap.py` 会自动把四个 core 模块加载到 `sys.path`):
```bash
python -m unittest discover -s tests/ha -v
```
## 文件
- `_bootstrap.py` —— 内核模块加载引导(edge-gateway 裸导入;data-bus / rag-kb /
llm-gateway 注册为下划线包名)。
- `test_availability_dr.py` —— 四个演练场景的 unittest 用例,含 `_FlakyDriver`
故障注入驱动。
+14
View File
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""iAOP 可用性 / 容灾演练套件(Issue #89,对应 PRD 第 9 章 NFR)。
验证目标(PRD 第 9 章 SLA「可用性 ≥ 99.8%」+ 容灾/断点续传):
- #89 可用性 / 容灾演练:注入驱动级、上行级、进程级故障,验证
- 采集可用性 ≥ 99.8%(轮次级健康度);
- 进程重启后 spool 断点续传:未确认样本零丢失重发;
- 上行通道抖动不丢数据,仅增加本地缓存占用;
- 单驱动故障被隔离,不拖垮网关,SLA 仍达标。
全部基于各模块可注入接口运行,零外部依赖,CI 可直接执行。
运行:在仓库根目录执行 `python -m unittest discover -s tests/ha -v`
(_bootstrap.py 会自动加载四个 core 模块)。
"""
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""容灾演练套件引导:复用内核模块加载逻辑。
与 tests/perf/_bootstrap.py / tests/e2e/_bootstrap.py 等价:把 edge-gateway
加入 sys.path(裸导入),把 data-bus / rag-kb / llm-gateway 注册为下划线
包名(带连字符目录)。
"""
from __future__ import annotations
import importlib.util
import os
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
_REPO_ROOT = os.path.abspath(os.path.join(_THIS_DIR, "..", ".."))
_CORE_DIR = os.path.join(_REPO_ROOT, "core")
_EDGE_DIR = os.path.join(_CORE_DIR, "edge-gateway")
if os.path.isdir(_EDGE_DIR) and _EDGE_DIR not in sys.path:
sys.path.insert(0, _EDGE_DIR)
def _register_dashed_package(pkg_name: str, dir_path: str) -> None:
init_file = os.path.join(dir_path, "__init__.py")
if not os.path.isfile(init_file) or pkg_name in sys.modules:
return
spec = importlib.util.spec_from_file_location(
pkg_name, init_file, submodule_search_locations=[dir_path]
)
if spec is None or spec.loader is None:
return
module = importlib.util.module_from_spec(spec)
sys.modules[pkg_name] = module
spec.loader.exec_module(module)
_register_dashed_package("data_bus", os.path.join(_CORE_DIR, "data-bus"))
_register_dashed_package("rag_kb", os.path.join(_CORE_DIR, "rag-kb"))
_register_dashed_package("llm_gateway", os.path.join(_CORE_DIR, "llm-gateway"))
+278
View File
@@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
"""可用性 / 容灾演练(Issue #89,对应 PRD 第 9 章 NFR)。
演练场景与验证目标:
1. 可用性基线:600 点位 30 轮健康采集,可用性 ≥ 99.8%、丢失率 ≤ 0.02%;
2. 驱动级故障注入:单驱动按计划间歇抛异常,引擎记失败轮但**不中断**,
跨足够轮次后整体可用性仍受控(故障被隔离,正常轮次仍产出样本);
3. 上行通道抖动:Kafka 不可用期间样本全部驻留 spool,恢复后断点续传,
全量样本零丢失(断点续传 = 丢失率保障的实现机制);
4. 进程重启容灾:模拟网关崩溃重启 —— 新建 SpoolStore 扫描同一目录,
未确认记录全部重发,落库样本数 = 采集样本数(RPO=0)。
全部基于内核可注入接口(Driver / SpoolStore / HealthMetrics / BatchWriter)
合成执行,零外部依赖,CI 可重复运行。
"""
from __future__ import annotations
# 引导加载内核模块
import tests.ha._bootstrap # noqa: F401
import os
import tempfile
import time
import unittest
from collector.engine import CollectorEngine
from collector.metrics import HealthMetrics
from collector.spool import SpoolStore
from drivers import SimulatorDriver
from drivers.base import Driver, SampleValue
from point_dict.loader import Point, PointDict
from data_bus import BatchWriter, MemorySink
def _make_point_dict(n_points: int = 600) -> PointDict:
"""合成 600 点位(PRD 5.1 基线:600 点位 1Hz),分布到 12 台设备。"""
points = []
n_devices = max(1, n_points // 50) # 每台设备约 50 点位
for i in range(n_points):
dev = i % n_devices
points.append(Point(
device_id=f"CLF-{dev+1:02d}",
point_id=f"CLF-{dev+1:02d}.P{i:04d}",
name=f"测点{i}",
unit="℃",
data_type="float",
sample_rate=1000,
quality_code=True,
row_number=i + 2,
))
return PointDict(points)
class _FlakyDriver(Driver):
"""按计划间歇抛异常的故障驱动(容灾演练用)。
每轮读取根据注入策略决定是否抛 ConnectionError,模拟现场设备掉线 /
通讯中断。`fail_rounds` 为一个 set,元素为「本轮要抛异常」的轮次序号
(从 0 起计)。其余轮次正常返回模拟值。
"""
protocol = "simulator"
def __init__(self, fail_rounds=None):
super().__init__({})
self._inner = SimulatorDriver()
self.fail_rounds = set(fail_rounds or [])
self._tick = -1
def connect(self) -> None:
return None
def read_points(self, points):
self._tick += 1
if self._tick in self.fail_rounds:
# 模拟设备掉线 / 通讯中断
raise ConnectionError(f"演练:第 {self._tick} 轮设备通讯中断")
return self._inner.read_points(points)
def close(self) -> None:
return None
class AvailabilityBaselineDrillTest(unittest.TestCase):
"""场景 1:健康采集可用性基线(PRD 9 章 ≥ 99.8%)。"""
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="iaop_ha_baseline_")
self.point_dict = _make_point_dict(600)
self.spool = SpoolStore(spool_dir=self._tmp, cache_limit_bytes=64 * 1024 * 1024)
self.metrics = HealthMetrics()
self.engine = CollectorEngine(
point_dict=self.point_dict,
driver_slots=[("simulator", SimulatorDriver(), [])],
spool=self.spool, metrics=self.metrics,
interval_ms=1000, max_pending=500_000,
)
def test_availability_under_threshold(self) -> None:
"""30 轮健康采集,可用性 ≥ 99.8%、丢失率 ≤ 0.02%、P99 ≤ 1.8s。"""
rounds = 30
for _ in range(rounds):
self.engine.collect_once()
self.assertGreaterEqual(self.metrics.availability, 0.998,
f"可用性 {self.metrics.availability:.4%} < 99.8%(PRD 9 章)")
self.assertLessEqual(self.metrics.loss_rate, 0.0002,
f"丢失率 {self.metrics.loss_rate:.4%} > 0.02%")
self.assertLessEqual(self.metrics.p99_latency(), 1.8,
f"P99 {self.metrics.p99_latency():.4f}s > 1.8s")
self.assertTrue(self.metrics.meets_sla())
print(f"\n[可用性基线] {rounds} 轮健康采集,"
f"可用性={self.metrics.availability:.4%},"
f"丢失率={self.metrics.loss_rate:.6%},"
f"P99={self.metrics.p99_latency()*1000:.1f}ms → SLA 达标")
class DriverFaultIsolationDrillTest(unittest.TestCase):
"""场景 2:驱动级故障注入 —— 单驱动间歇掉线,引擎记失败轮但不中断。
演练口径:30 轮中注入 5 轮驱动异常(掉线)。引擎对驱动异常按「本轮整体
失败」计入 HealthMetrics(failed_rounds +1)并立即返回,不抛出、不崩溃。
验证:
- 引擎本身未被故障拖垮(异常被隔离,无未捕获异常抛出);
- 可用性 = 1 - failed/total = 1 - 5/30 ≈ 83.3%(受控下降,符合预期,
说明统计口径正确反映故障);故障占比与可用性下降幅度一致;
- 剩余 25 个健康轮次仍正常产出样本(25 × 600 = 15000 条驻留 spool)。
"""
def test_driver_fault_isolated_and_metrics_accurate(self) -> None:
tmp = tempfile.mkdtemp(prefix="iaop_ha_fault_")
point_dict = _make_point_dict(600)
spool = SpoolStore(spool_dir=tmp, cache_limit_bytes=64 * 1024 * 1024)
metrics = HealthMetrics()
fail_rounds = {3, 7, 12, 18, 24} # 30 轮中注入 5 轮设备掉线
flaky = _FlakyDriver(fail_rounds=fail_rounds)
engine = CollectorEngine(
point_dict=point_dict,
driver_slots=[("simulator", flaky, [])],
spool=spool, metrics=metrics,
interval_ms=1000, max_pending=500_000,
)
rounds = 30
for _ in range(rounds):
# 故障被隔离:不应抛出未捕获异常
engine.collect_once()
# 统计口径正确:失败轮次 = 注入轮次数
self.assertEqual(metrics.failed_rounds, len(fail_rounds))
self.assertEqual(metrics.total_rounds, rounds)
expected_avail = 1.0 - len(fail_rounds) / rounds
self.assertAlmostEqual(metrics.availability, expected_avail, places=6)
# 健康轮次仍正常产出样本(5 个掉线轮不产出,25 轮 × 600 = 15000)
pending = spool.total_pending()
self.assertEqual(pending, (rounds - len(fail_rounds)) * len(point_dict))
# 故障期间丢失率口径:掉线轮的应采样本计入丢失
# 丢失样本 = 5 轮 × 600 = 3000,应采 = 30 × 600 = 18000
self.assertEqual(metrics.lost_samples, len(fail_rounds) * len(point_dict))
print(f"\n[驱动故障隔离] 注入 {len(fail_rounds)} 轮设备掉线,"
f"引擎未崩溃;可用性={metrics.availability:.2%}"
f"(受控下降,故障被隔离),健康轮次产出 {pending} 条样本")
class UpstreamOutageResumeDrillTest(unittest.TestCase):
"""场景 3:上行通道抖动 —— Kafka 不可用期间样本驻留 spool,恢复后断点续传。
演练口径:模拟上行(Kafka)连续不可用 N 轮,期间 collect_once 不传 sink
(离线采集模式,样本全部落 spool)。恢复后把 spool 全量重发到 BatchWriter。
验证:
- 抖动期间零丢失(样本全部驻留本地 spool);
- 恢复后断点续传落库数 = 采集数(不丢不重)。
"""
def test_upstream_outage_then_resume_no_loss(self) -> None:
tmp = tempfile.mkdtemp(prefix="iaop_ha_upstream_")
point_dict = _make_point_dict(600)
spool = SpoolStore(spool_dir=tmp, cache_limit_bytes=64 * 1024 * 1024)
metrics = HealthMetrics()
engine = CollectorEngine(
point_dict=point_dict,
driver_slots=[("simulator", SimulatorDriver(), [])],
spool=spool, metrics=metrics,
interval_ms=1000, max_pending=500_000,
)
outage_rounds = 5 # 上行连续中断 5 轮
total_rounds = 10
# 中断期:离线采集(不传 sink),样本全部驻留 spool
for _ in range(outage_rounds):
engine.collect_once(sink=None)
# 恢复后继续采集 5 轮(仍离线,便于精确比对驻留量)
for _ in range(total_rounds - outage_rounds):
engine.collect_once(sink=None)
# 抖动期间零丢失:采集可用性 100%
self.assertEqual(metrics.failed_rounds, 0)
self.assertGreaterEqual(metrics.availability, 0.998)
# 恢复:spool 全量重发到 BatchWriter,断点续传落库
sink = MemorySink()
writer = BatchWriter(sink=sink, batch_size=1000, flush_interval=0.0)
accepted = 0
for sample in spool.pending_records():
if writer.push(sample):
accepted += 1
spool.ack(sample)
writer.flush()
expected = total_rounds * len(point_dict)
self.assertEqual(accepted, expected)
self.assertEqual(len(sink.rows), expected)
# 重发后 spool 清空(全部 ack)
self.assertEqual(spool.total_pending(), 0)
print(f"\n[上行抖动恢复] 上行中断 {outage_rounds} 轮期间样本全部驻留 spool,"
f"恢复后断点续传落库 {accepted} 条(零丢失,RPO=0)")
class ProcessRestartResumeDrillTest(unittest.TestCase):
"""场景 4:进程重启容灾 —— 网关崩溃重启后断点续传。
演练口径:
1. 采集若干轮(样本写入 spool,模拟尚未上行确认);
2. **销毁引擎对象**(模拟进程崩溃 / 重启)—— 丢弃内存中的引擎/spool 句柄,
仅保留磁盘上的 spool 文件;
3. 新建 SpoolStore 指向同一目录(模拟重启后扫描 spool),把未确认记录
全部重发落库。
验证:RPO=0 —— 重启未丢任何未确认样本,落库数 = 崩溃前采集数。
这正是 spool 断点续传机制(PRD 9 章「丢失率 ≤ 0.02%」的实现保障)的
容灾价值。
"""
def test_restart_resumes_unacked_samples(self) -> None:
tmp = tempfile.mkdtemp(prefix="iaop_ha_restart_")
point_dict = _make_point_dict(600)
rounds_before_crash = 8
# ---- 阶段 1:崩溃前采集(样本落 spool,全部未 ack)----
spool1 = SpoolStore(spool_dir=tmp, cache_limit_bytes=64 * 1024 * 1024)
metrics1 = HealthMetrics()
engine1 = CollectorEngine(
point_dict=point_dict,
driver_slots=[("simulator", SimulatorDriver(), [])],
spool=spool1, metrics=metrics1,
interval_ms=1000, max_pending=500_000,
)
for _ in range(rounds_before_crash):
engine1.collect_once(sink=None) # 离线采集,样本待上行
collected = rounds_before_crash * len(point_dict)
self.assertEqual(spool1.total_pending(), collected)
# ---- 阶段 2:模拟进程崩溃 —— 丢弃内存对象,仅留磁盘 spool ----
del engine1, spool1, metrics1
# ---- 阶段 3:重启后扫描同一 spool 目录,断点续传重发 ----
spool2 = SpoolStore(spool_dir=tmp, cache_limit_bytes=64 * 1024 * 1024)
sink = MemorySink()
writer = BatchWriter(sink=sink, batch_size=1000, flush_interval=0.0)
accepted = 0
for sample in spool2.pending_records():
if writer.push(sample):
accepted += 1
spool2.ack(sample)
writer.flush()
# RPO=0:重启未丢任何未确认样本
self.assertEqual(accepted, collected)
self.assertEqual(len(sink.rows), collected)
# 全部确认后 spool 清空
self.assertEqual(spool2.total_pending(), 0)
print(f"\n[进程重启容灾] 崩溃前采集 {collected} 条未确认样本,"
f"重启后断点续传全部重发落库(RPO=0,零丢失)")
if __name__ == "__main__":
unittest.main()