backend: store adaptive rate changes as events and serve an audit of them per line

This commit is contained in:
justme committed 2026-10-22 10:44:46 +08:00
1 parent d63c3bff74
commit 9ad34acbac
9 files changed
+371

No files matched your search

@@ -0,0 +1,107 @@
using System.Text.Json.Nodes;
using Fleet.Core;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
public sealed class AdaptiveAuditTests : IClassFixture<BackendFixture>
{
private static readonly long s_t0 = new DateTimeOffset(2026, 10, 22, 10, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds();
private const long Minute = 60_000;
private const long Hour = 3_600_000;
private readonly BackendFixture _backend;
public AdaptiveAuditTests(BackendFixture backend)
{
_backend = backend;
}
private static RateChange Change(long atMs, string kind, string from, string to, double hz, string reason = "quiet", double lossPct = 0) =>
new() { AtMs = atMs, Kind = kind, From = from, To = to, Hz = hz, Reason = reason, LossPct = lossPct, Sent = 1000, Lost = (int)(lossPct * 10) };
/// <summary>状态变化还原成每个时间桶里各状态的秒数:跨桶的一段要切开,区间开头的状态取第一次变化的「从」,不同方法各算各的。</summary>
[Fact]
public void Summarize_SplitsStateTimeAcrossBuckets_AndWeightsTheAverageRateByTime()
{
RateChange[] changes =
[
Change(s_t0 + (30 * Minute), "quic_ping", "watch", "quiet", 2), // 10:00–10:30 watch(频率未知),之后 quiet 2 Hz
Change(s_t0 + (90 * Minute), "quic_ping", "quiet", "active", 10, "sustained loss", 0.4), // 11:30 起 active 10 Hz
Change(s_t0 + (30 * Minute), "tcp_ping", "quiet", "watch", 20, "rtt above baseline"),
];
AdaptiveResult result = AdaptiveAudit.Summarize(changes, s_t0, s_t0 + (2 * Hour), Hour);
AdaptiveKind quic = Assert.Single(result.Kinds, k => k.Kind == "quic_ping");
Assert.Equal(2, quic.Buckets.Count);
AdaptiveBucket first = quic.Buckets[0];
Assert.Equal((1800, 1800, 0), ((int)first.WatchSeconds, (int)first.QuietSeconds, (int)first.ActiveSeconds)); // 10:00–10:30 watch,10:30–11:00 quiet
Assert.Equal(2, first.AverageHz); // 只有 quiet 那半小时知道频率
Assert.Equal(1, first.Changes);
AdaptiveBucket second = quic.Buckets[1];
Assert.Equal((1800, 1800), ((int)second.QuietSeconds, (int)second.ActiveSeconds)); // 11:00–11:30 quiet,11:30–12:00 active
Assert.Equal(6, second.AverageHz); // (2 Hz × 30 min + 10 Hz × 30 min) / 60 min
Assert.Equal((1, 0.4), (second.Changes, second.MaxLossPct));
AdaptiveKind tcp = Assert.Single(result.Kinds, k => k.Kind == "tcp_ping");
Assert.Equal((1800, 1800), ((int)tcp.Buckets[0].QuietSeconds, (int)tcp.Buckets[0].WatchSeconds));
Assert.Equal(3600, (int)tcp.Buckets[1].WatchSeconds);
Assert.Equal(3, result.Changes.Count);
Assert.Equal("quic_ping", result.Changes[0].Kind); // 最新的在前
}
/// <summary>变化在区间开头之前发生:那之后的状态延续到区间里。</summary>
[Fact]
public void Summarize_CarriesTheStateInFromBeforeTheRange()
{
RateChange[] changes = [Change(s_t0 - (2 * Hour), "quic_ping", "quiet", "active", 10, "sustained loss", 1)];
AdaptiveResult result = Summarize(changes);
AdaptiveBucket bucket = Assert.Single(Assert.Single(result.Kinds).Buckets);
Assert.Equal(3600, (int)bucket.ActiveSeconds);
Assert.Equal((10, 0), (bucket.AverageHz, bucket.Changes));
Assert.Empty(result.Changes); // 区间里没有变化
AdaptiveResult Summarize(RateChange[] c) => AdaptiveAudit.Summarize(c, s_t0, s_t0 + Hour, Hour);
}
/// <summary>端到端:拨号节点随心跳上报的状态变化存成事件,几天后还能通过接口按时段查到。</summary>
[Fact]
public async Task RateChangesReportedInHeartbeats_AreStored_AndServedByTheAdaptiveEndpoint()
{
await using AgentSimulator first = await _backend.ConnectAgentAsync();
await using AgentSimulator second = await _backend.ConnectAgentAsync();
(AgentSimulator dialer, AgentSimulator listener) = string.CompareOrdinal(first.NodeId.Value, second.NodeId.Value) < 0 ? (first, second) : (second, first); // 小 id 拨大 id
long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
RateChange Of(long atMs, string from, string to, double hz, string reason, double loss)
{
RateChange change = Change(atMs, "quic_ping", from, to, hz, reason, loss);
change.Listener = listener.NodeId.Value;
change.IpVersion = "ipv4";
return change;
}
await dialer.SendRateChangesAsync(Of(now - (20 * Minute), "watch", "quiet", 2, "quiet", 0), Of(now - (10 * Minute), "quiet", "active", 10, "sustained loss", 0.5));
string lineId = listener.NodeId.Value + "~" + dialer.NodeId.Value;
JsonNode? audit = null;
await BackendFixture.EventuallyAsync(async () =>
{
audit = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{lineId}/adaptive?range=1h&step=10m");
return audit["changes"]!.AsArray().Count == 2;
});
JsonNode quic = Assert.Single(audit!["kinds"]!.AsArray())!;
Assert.Equal("quic_ping", quic["kind"]!.GetValue<string>());
double active = quic["buckets"]!.AsArray().Sum(b => b!["active_seconds"]!.GetValue<double>());
Assert.InRange(active, 500, 700); // 最近 10 分钟 active
JsonNode newest = audit!["changes"]![0]!;
Assert.Equal(("quiet", "active", "sustained loss"), (newest["from"]!.GetValue<string>(), newest["to"]!.GetValue<string>(), newest["reason"]!.GetValue<string>()));
}
}