udp_echo and tcp_echo run at the lowest rate that still proves the loss bound and keeps the RTT p99 confidence interval within tolerance, go to the maximum when the line loses packets or its RTT rises, and back off exponentially when it is unreachable. Every echo row carries a weight (the time it stands for, in grid intervals) and the analysis ops accumulate loss and RTT by it, so p50/p95/p99 and loss stay time-averaged when the rate changes. State changes are logged for audit.
176 lines
6.6 KiB
C#
176 lines
6.6 KiB
C#
using MoShiro.Measurement;
|
||
|
||
namespace MoShiro.Measurement.Tests;
|
||
|
||
public sealed class AdaptiveRateTests
|
||
{
|
||
private const long Second = 1_000_000;
|
||
|
||
/// <summary>按当前频率发包、按 <paramref name="lost"/> 决定回不回,每秒评估一次;返回结束时的秒数。</summary>
|
||
private static long Run(AdaptiveRate rate, long fromSecond, int seconds, Func<long, bool> lost, double rttMs = 20, List<ProbeTransition>? log = null, bool stopAtDead = false)
|
||
{
|
||
long second = fromSecond;
|
||
for (int i = 0; i < seconds; i++, second++)
|
||
{
|
||
int probes = Math.Max(1, (int)Math.Round(rate.Hz));
|
||
if (rate.State == ProbeState.Dead)
|
||
{
|
||
probes = i % 4 == 0 ? 1 : 0; // 退避:几秒才发一个
|
||
}
|
||
|
||
for (int k = 0; k < probes; k++)
|
||
{
|
||
long sent = second * Second + k * Second / probes;
|
||
rate.OnSend(sent);
|
||
if (!lost(second))
|
||
{
|
||
rate.OnReply(sent, sent + (long)(rttMs * 1000), rttMs);
|
||
}
|
||
}
|
||
|
||
if (rate.Evaluate((second + 1) * Second) is { } change)
|
||
{
|
||
log?.Add(change);
|
||
if (stopAtDead && change.To == ProbeState.Dead)
|
||
{
|
||
return second + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
return second;
|
||
}
|
||
|
||
[Fact]
|
||
public void QuietLine_AfterWarmUp_ProbesJustEnoughToProveTheLossBound()
|
||
{
|
||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||
long at = Run(rate, 0, 600, _ => false);
|
||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||
Assert.Equal(5, rate.Hz, 0.01); // 3 / (0.2% × 300 s)
|
||
Assert.True(at > 0);
|
||
}
|
||
|
||
[Fact]
|
||
public void SustainedLoss_RaisesToTheMaximum_AndComesBackOnlyAfterTheHoldTimes()
|
||
{
|
||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||
long at = Run(rate, 0, 600, _ => false);
|
||
at = Run(rate, at, 60, second => second % 10 == 0); // 每 10 秒丢一整秒的包
|
||
Assert.Equal(ProbeState.Active, rate.State);
|
||
Assert.Equal(10, rate.Hz);
|
||
|
||
at = Run(rate, at, 800, _ => false); // 不到 15 分钟:还在 Active
|
||
Assert.Equal(ProbeState.Active, rate.State);
|
||
at = Run(rate, at, 400, _ => false); // 超过 15 分钟:降到 Watch
|
||
Assert.Equal(ProbeState.Watch, rate.State);
|
||
Run(rate, at, 1900, _ => false); // 最后一次丢包 30 分钟之后:回到 Quiet
|
||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void AnIsolatedLoss_OnlyRaisesToWatch()
|
||
{
|
||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||
long at = Run(rate, 0, 600, _ => false);
|
||
var log = new List<ProbeTransition>();
|
||
Run(rate, at, 120, second => second == at + 10, log: log);
|
||
Assert.Equal(ProbeState.Watch, rate.State);
|
||
Assert.DoesNotContain(log, change => change.To == ProbeState.Active);
|
||
}
|
||
|
||
[Fact]
|
||
public void Unreachable_BacksOffExponentially_AndRecoversOnTheFirstReply()
|
||
{
|
||
var rate = new AdaptiveRate(new AdaptiveRateOptions(), new Random(1));
|
||
long at = Run(rate, 0, 600, _ => false);
|
||
at = Run(rate, at, 60, _ => true, stopAtDead: true);
|
||
Assert.Equal(ProbeState.Dead, rate.State);
|
||
|
||
var delays = new List<double>();
|
||
long now = at * Second;
|
||
for (int i = 0; i < 8; i++)
|
||
{
|
||
double delay = rate.NextDelay().TotalSeconds;
|
||
delays.Add(delay);
|
||
now += (long)(delay * Second);
|
||
rate.OnSend(now);
|
||
}
|
||
|
||
Assert.InRange(delays[0], 0.8, 1.2); // 从 1 秒开始
|
||
Assert.True(delays[3] > delays[0] * 4, string.Join(",", delays)); // 翻倍增长(有抖动)
|
||
Assert.All(delays, delay => Assert.InRange(delay, 0.8, 36.0)); // 最长 30 秒,抖动 ±20%
|
||
|
||
rate.OnReply(now, now + 30_000, 30);
|
||
Assert.Equal(ProbeState.Watch, rate.Evaluate(now + 2 * Second)!.To);
|
||
}
|
||
|
||
[Fact]
|
||
public void QuietLineWithTightRtt_MayDropBelowTheGrid_ButAJitteryOneStaysAtTheMaximum()
|
||
{
|
||
var tight = new AdaptiveRate(new AdaptiveRateOptions());
|
||
Run(tight, 0, 1200, _ => false, rttMs: 20); // RTT 恒定:p99 的置信区间几乎为零,降到丢包下界 5 Hz
|
||
Assert.Equal(ProbeState.Quiet, tight.State);
|
||
Assert.Equal(5, tight.Hz, 0.01);
|
||
|
||
var jittery = new AdaptiveRate(new AdaptiveRateOptions());
|
||
var random = new Random(7);
|
||
long second = 0;
|
||
for (int i = 0; i < 1200; i++, second++)
|
||
{
|
||
int probes = Math.Max(1, (int)Math.Round(jittery.Hz));
|
||
for (int k = 0; k < probes; k++)
|
||
{
|
||
long sent = second * Second + k * Second / probes;
|
||
double rtt = 20 + random.NextDouble() * random.NextDouble() * 60; // 长尾 20 到 80 ms
|
||
jittery.OnSend(sent);
|
||
jittery.OnReply(sent, sent + (long)(rtt * 1000), rtt);
|
||
}
|
||
|
||
jittery.Evaluate((second + 1) * Second);
|
||
}
|
||
|
||
Assert.Equal(10, jittery.Hz, 0.01); // 分位数精度保护:抖动大,样本不能少
|
||
}
|
||
|
||
[Fact]
|
||
public void Weight_IsTheTimeAProbeStandsFor_InUnitsOfTheGrid()
|
||
{
|
||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||
Run(rate, 0, 1200, _ => false);
|
||
Assert.Equal(5, rate.Hz, 0.01);
|
||
rate.NextDelay();
|
||
Assert.Equal(2, rate.Weight); // 5 Hz 的一个包代表 2 个 10 Hz 网格间隔
|
||
}
|
||
|
||
[Fact]
|
||
public void RttWellAboveTheBaseline_RaisesToWatch()
|
||
{
|
||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||
long at = Run(rate, 0, 900, _ => false, rttMs: 20);
|
||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||
Run(rate, at, 30, _ => false, rttMs: 120);
|
||
Assert.Equal(ProbeState.Watch, rate.State);
|
||
}
|
||
|
||
[Fact]
|
||
public void Prior_WarmsUpBeforeTheEveningPeak_AndLetsHistoricallyQuietHoursUseTheLowestRate()
|
||
{
|
||
const long peakStart = 1_000 * 3600; // 秒
|
||
double? Prior(long us)
|
||
{
|
||
long second = us / Second;
|
||
return second >= peakStart && second < peakStart + 4 * 3600 ? 0.8 : 0.0;
|
||
}
|
||
|
||
var rate = new AdaptiveRate(new AdaptiveRateOptions { Prior = Prior });
|
||
long at = Run(rate, 100_000, 1200, _ => false); // 远离高峰,历史平稳:证明窗口拉长,2 Hz
|
||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||
Assert.Equal(2, rate.Hz, 0.01);
|
||
|
||
Run(rate, peakStart - 1500, 120, _ => false); // 距离高峰不到半小时:提前升到 Active
|
||
Assert.Equal(ProbeState.Active, rate.State);
|
||
Assert.True(at > 0);
|
||
}
|
||
}
|