diff --git a/tests/ha/README.md b/tests/ha/README.md new file mode 100644 index 0000000..c5c0b18 --- /dev/null +++ b/tests/ha/README.md @@ -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` + 故障注入驱动。 diff --git a/tests/ha/__init__.py b/tests/ha/__init__.py new file mode 100644 index 0000000..85d6b3d --- /dev/null +++ b/tests/ha/__init__.py @@ -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 模块)。 +""" diff --git a/tests/ha/_bootstrap.py b/tests/ha/_bootstrap.py new file mode 100644 index 0000000..7668fae --- /dev/null +++ b/tests/ha/_bootstrap.py @@ -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")) diff --git a/tests/ha/test_availability_dr.py b/tests/ha/test_availability_dr.py new file mode 100644 index 0000000..c5508c3 --- /dev/null +++ b/tests/ha/test_availability_dr.py @@ -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()