fix(退避): 严格单调断言偶发失败——是断言过严,不是 jitter 源码 bug

## 结论:断言过严(抖动实现本身正确,未改退避算法)
`apply_jitter_downward` 只向下扣 [0, capped*ratio],结果恒落在
[capped*(1-ratio), capped] 内,**绝不突破 max_ms** —— 上限这条硬承诺没被破坏,
抖动叠加方式也没有写错,不存在"源码 bug"那一路。
严格单调不成立是设计内的必然:封顶后 capped 恒为 60s,扣减量
offset = (now_ms() ^ capped.rotate_left(13)) % (span+1),每跨 1 毫秒 offset 就 +1,
于是后一个样本比前一个正好小 1ms(实测 59009 → 59008、50901 → 50900)。
原断言要求"相邻样本单调不减",等于要求这件不可能的事。

## 复现(真复现,非推测)
- 并行单跑该用例:48 次内命中
  `退避应单调不减: [... 57955, 57954, 57954 ...]`
- 变异回严格单调后,16 进程 × --test-threads=61 并发整包跑:第 1600 次命中
  `[... 50901, 50901, 50901, 50900 ...]`
单跑命中率约 1/1000(12 次调用里只有封顶区那段约 1µs 窗口可能跨毫秒),
这正是它"平时全绿、负载高才红"的原因。

## 改动
- transport.rs 主用例改按**档位表**断言(capped(n)=min(base*2^n, cap)):
  每个样本必须落在本档位抖动带 [level*(1-ratio), level];相邻样本回落不得超过
  一个抖动幅度(这才是"防退化"该守的不变量);末次必须真的顶进封顶带。
  注释写清为什么不能要求严格单调,防后人改回去。
- 新增 capped_backoff_dips_within_jitter_band:在封顶区**跨毫秒**采样,
  断言"必然观察到回落,且回落幅度 ≤ 一个抖动幅度",把"回落是正常行为"
  钉成可执行断言(而不是靠注释约定)。
- 新增 Backoff::base_delay_ms / max_delay_ms / jitter_ratio 只读访问器:
  测试不再重抄 1000 / 60_000 / 0.3,避免第二个真相源。
- buffer.rs 头注释 "±jitter" 与实现(只向下)不符,一并改正。

## 变异自证
1) 把 dips 用例的容差断言改成 `after >= before`(严格单调)→ 确定性变红 3/3,
   报文 `45363 → 45362`。
2) 把主用例容差断言改回 `w[1] >= w[0]` → 上述并发压测第 1600 次变红,
   特征与原报告一致;恢复后 500 次整包并发复跑全绿。

## 测试
改前 60 passed / 0 failed;改后 61 passed / 0 failed(净增 1 条)。
仅动 rust-core/src/buffer.rs 与 rust-core/src/transport.rs,server/ tests/ client/ deploy/ 未触碰。
This commit is contained in:
PMon Dev committed 2026-10-27 08:09:52 +08:00
1 parent 14395692e4
commit e9c02c981e
2 files changed
+131 -6

No files matched your search

+30 -1
View File
@@ -68,8 +68,15 @@ impl<T> RingBuffer<T> {
/// 指数退避 + 随机抖动
///
/// 规则(见方案 V4.0 13.2):
/// delay(n) = min(base * 2^n, max),再叠加 ±jitter 比例的随机量
/// delay(n) = min(base * 2^n, max),再**向下**扣掉 [0, jitter] 比例的随机量
/// 抖动的作用:即使海量设备同时断线,重连时刻也会被打散,避免"重连风暴"。
///
/// ⚠️ 抖动**只向下,不是 ±**(原注释写成 ±,与实现不符,已改):
/// 上限是硬承诺,正向抖会在 `min(base*2^n, max)` 之后突破 max_ms。
/// 由此带出一个必须知道的后果:**封顶之后相邻两次延迟不保证单调不减** ——
/// 后一次可能比前一次小(实测 59009 → 59008,因为扣减量随 `now_ms()` 变化)。
/// 调用方与测试都不要对带抖动的值断言严格单调,详见
/// `transport::tests::capped_backoff_dips_within_jitter_band`。
pub struct Backoff {
base_ms: u64,
max_ms: u64,
@@ -114,6 +121,28 @@ impl Backoff {
pub fn attempt(&self) -> u32 {
self.attempt
}
/// 起步延迟(毫秒)。
///
/// 对外只读:让测试与其他模块引用**策略真值**,而不必重抄 `1000` 这个字面量
/// ("同一份数值写两处"是本仓库反复翻车的坑:指标字段、配对码契约、安装路径…)。
pub fn base_delay_ms(&self) -> u64 {
self.base_ms
}
/// 延迟上限(毫秒)。
///
/// 「重连风暴防线的硬承诺」的对外读数 —— 任何延迟都必须 ≤ 它。
pub fn max_delay_ms(&self) -> u64 {
self.max_ms
}
/// 抖动比例(0.0–1.0,只向下生效)。
///
/// 断言"抖动幅度没超设计"时需要它;不要在别处硬编码 0.3。
pub fn jitter_ratio(&self) -> f64 {
self.jitter_ratio
}
}
/// 施加向下抖动:返回 [value*(1-ratio), value] 区间内的值。
+101 -5
View File
@@ -618,6 +618,8 @@ impl Transport {
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::Backoff;
use crate::collector::now_ms;
use serde_json::json;
fn env(seq: u64) -> Envelope {
@@ -854,42 +856,136 @@ mod tests {
#[test]
fn backoff_grows_and_is_capped_across_repeated_failures() {
// 策略值一律**从 Backoff 自己取**,不在测试里重抄 1000 / 60_000 / 0.3
// (同一份数值写两处 = 第二个真相源,改策略时测试会"跟着一起错")
let policy = Backoff::default_policy();
let cap = policy.max_delay_ms(); // 60_000
let ratio = policy.jitter_ratio(); // 0.3
let span = (cap as f64 * ratio) as u64; // 抖动最大幅度 18_000
let mut tr = Transport::new("dev-1");
let mut now = 0u64;
let mut delays = Vec::new();
// 档位表:capped(n) = min(base * 2^n, cap) —— 与 Backoff::next_delay_ms 同源
let mut level = policy.base_delay_ms();
// 连续失败(期间从未连上):退避应逐级增长
// 连续失败(期间从未连上):每个样本都必须落在**本档位**的抖动带内
for _ in 0..12 {
let d = tr.on_disconnected(now);
let floor = level - (level as f64 * ratio) as u64;
assert!(
d <= level,
"退避突破本档位上限(抖动只应向下,不该超过 level={}): {}",
level,
d
);
assert!(d >= floor, "抖动幅度超出设计(level={}): {}", level, d);
delays.push(d);
now += d;
level = level.saturating_mul(2).min(cap);
}
// 单调不减(抖动只向下取,故允许相等)
// ── 增长性与"防退化"守卫 ────────────────────────────────────
// 增长性由**档位表**保证(每档翻倍直到封顶),不靠相邻采样的大小关系。
//
// ⚠️ 为什么**不能**断言"严格单调不减"(这条曾经这么写,会偶发失败):
// 抖动是向下取:delay = capped - offset,offset 由 `now_ms()` 派生。
// 一旦封顶,capped 恒为 60s,offset 每跨 1 毫秒就 +1 —— 于是后一个样本
// 完全可能比前一个**小 1ms**(实测 59009 → 59008,负载高时循环更容易
// 撞上毫秒边界)。那是本函数**设计内的正常行为**,不是退避退化。
//
// 真正要守住的不变量是:相邻样本的回落不超过一个抖动幅度。
for w in delays.windows(2) {
assert!(w[1] >= w[0], "退避应单调不减: {:?}", delays);
assert!(
w[1].saturating_add(span) >= w[0],
"相邻退避回落超过一个抖动幅度(这是退避退化,不是抖动): {:?}",
delays
);
}
// 封顶 60s —— 重连风暴防线的硬承诺
for d in &delays {
assert!(*d <= 60_000, "退避突破上限: {}", d);
assert!(*d <= cap, "退避突破上限: {}", d);
}
assert!(
delays[delays.len() - 1] > 10_000,
"退避应显著增长: {:?}",
delays
);
// 末次必须真的顶进封顶带,否则"封顶"这条断言根本没被覆盖到
assert!(
delays[delays.len() - 1] >= cap - span,
"末次退避应落在封顶带内: {:?}",
delays
);
// 一旦连上,退避档位归零:下次断线重新从 1s 起步(避免长期退避惩罚正常用户)
tr.on_connected(now);
assert_eq!(tr.backoff_attempt(), 0);
let after_recovery = tr.on_disconnected(now);
assert!(
after_recovery <= 1_000 && after_recovery > 0,
after_recovery <= policy.base_delay_ms() && after_recovery > 0,
"重连成功后应回到起步退避: {}",
after_recovery
);
}
/// 封顶之后抖动会让相邻延迟**回落** —— 这不是退化,是设计。
///
/// 这个用例存在的唯一理由:上面那条用例曾把"相邻样本严格单调不减"当不变量,
/// 于是偶发失败(抖动让后一个样本小 1ms)。后人看到容差断言"太宽松"时,
/// 很可能又想改回严格单调 —— 本用例把"回落是正常行为"钉成可执行断言:
/// 在封顶区**跨毫秒**连续采样,必然观察到回落,且回落幅度不超过一个抖动幅度。
#[test]
fn capped_backoff_dips_within_jitter_band() {
let policy = Backoff::default_policy();
let cap = policy.max_delay_ms();
let span = (cap as f64 * policy.jitter_ratio()) as u64;
let mut tr = Transport::new("dev-1");
let mut now = 0u64;
// 推到封顶档位:capped 此后恒为 cap,样本全落在 [cap-span, cap]
for _ in 0..8 {
now += tr.on_disconnected(now);
}
let mut prev = tr.on_disconnected(now);
let mut dip: Option<(u64, u64)> = None;
for _ in 0..50 {
// 等过一次毫秒边界再取下一个样本:抖动的熵来自 `now_ms()`,
// 同一毫秒内取到的是同一个扣减量。不等就等于什么都没测却假装通过。
let t = now_ms();
while now_ms() == t {
std::hint::spin_loop();
}
let cur = tr.on_disconnected(now);
assert!(
cur <= cap && cur.saturating_add(span) >= cap,
"封顶区样本必须落在抖动带 [{} , {}] 内: {}",
cap - span,
cap,
cur
);
if cur < prev {
dip = Some((prev, cur));
break;
}
prev = cur;
}
let (before, after) = dip.expect(
"封顶区跨毫秒采样必须观察到回落:抖动扣减量随 now_ms() 变化,跨毫秒就该变小。\
这里失败说明抖动已不随时间变化(或方向被改成了向上)——请复查 apply_jitter_downward",
);
assert!(
before - after <= span,
"回落幅度不得超过一个抖动幅度 {}: {} → {}",
span,
before,
after
);
}
#[test]
fn network_recovery_reconnects_immediately_without_waiting() {
let mut tr = Transport::new("dev-1");