measurement: adapt the echo probe rate to loss and RTT, and weight samples by the time they stand for

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.
This commit is contained in:
justme committed 2026-10-22 05:28:28 +08:00
1 parent 57acecdb86
commit e0376b5e33
15 files changed
+858 -55

No files matched your search

@@ -0,0 +1,175 @@
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);
}
}
@@ -65,7 +65,7 @@ public sealed class MeasurementTests
{
StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
var dialer = new RowStore(msg, Side.Dialer, 64);
Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
Assert.Equal(["size", "w", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
for (int i = 0; i < 4; i++)
@@ -78,7 +78,7 @@ public sealed class MeasurementTests
dialer.Set(2, dialer.ColumnIndex("t4"), 5000);
RawStream cut = dialer.Cut(1, 4, includeKey: true);
Assert.Equal(3, cut.Count);
Assert.Equal(["seq", "size", "t1", "t4"], cut.Columns.Select(c => c.Name));
Assert.Equal(["seq", "size", "w", "t1", "t4"], cut.Columns.Select(c => c.Name));
Assert.Equal([1L, 2L, 3L], cut.Find("seq")!.Values);
Assert.Equal([false, true, false], cut.Find("t4")!.Valid!);
@@ -111,10 +111,10 @@ public sealed class MeasurementTests
private static RawChunk EchoFragment(params (uint Seq, long T1, long? T4)[] rows)
{
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.U16("w"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
foreach ((uint seq, long t1, long? t4) in rows)
{
msg.Add(seq, 64, t1, t4);
msg.Add(seq, 64, 1, t1, t4);
}
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
@@ -131,7 +131,7 @@ public sealed class MeasurementTests
Result<IReadOnlyList<RawStream>> merged = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, source);
RawStream msg = Assert.Single(merged.Value);
Assert.Equal(["seq", "size", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
Assert.Equal(["seq", "size", "w", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
Assert.Equal([true, false, true], Enumerable.Range(0, 3).Select(i => msg.Find("t2")!.IsValid(i)));
Assert.Equal(2050, msg.Find("t3")!.Values[0]);
Assert.False(msg.Find("t4")!.IsValid(1));
@@ -92,6 +92,7 @@ public sealed class RawChunkCodecTests
public void GoldenBody_LayoutIsStable()
{
// 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。
// 只是回显流多了一列权重 w(块头自描述新增列,不用升级 container_version),黄金字节里多出六个 01。
RawChunk chunk = SampleChunk();
chunk.Header.Clock = new ChunkClock { ListenerMonoUs = 1000, UtcMs = 1_700_000_000_000, DialerOffsetUs = 500 };
byte[] body = RawChunkCodec.EncodeBody(chunk);
@@ -104,7 +105,7 @@ public sealed class RawChunkCodecTests
Assert.Equal(GoldenColumns, columns);
}
private const string GoldenColumns = "00020202020240404040404080fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
private const string GoldenColumns = "00020202020240404040404001010101010180fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
[Fact]
public void Decode_RejectsBadMagicVersionCrcAndTruncation()