Merge PR #97
This commit is contained in:
@@ -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`
|
||||
故障注入驱动。
|
||||
@@ -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 模块)。
|
||||
"""
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user