Files

69 lines
4.0 KiB
C#

using System.Text.Json.Nodes;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>重启后端:数据、身份和令牌都必须还在;热层从原始块回填;段文件尾部没有索引的数据被截断。</summary>
public sealed class PersistenceTests : IAsyncLifetime
{
private readonly BackendFixture _backend = new();
public ValueTask InitializeAsync() => _backend.InitializeAsync();
public ValueTask DisposeAsync() => _backend.DisposeAsync();
[Fact]
public async Task EverythingSurvivesARestart_AndTheHotLayerIsRebuiltFromRawChunks()
{
long minute = 60_000;
long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute;
AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
for (int k = 0; k < 12; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "quic_ping", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 4 == 0, 20_000, 20_000))));
}
string from = DateTimeOffset.FromUnixTimeMilliseconds(baseMs).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
string to = DateTimeOffset.FromUnixTimeMilliseconds(baseMs + (2 * minute)).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
string url = $"/api/public/lines/{_backend.Line(id)}/series?from={from}&to={to}&metrics=loss.total,rtt.p95";
JsonNode before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m");
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"))["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 1200);
before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m");
await agent.DisposeAsync();
await _backend.RestartAsync();
HttpClient admin = _backend.Admin; // 重启之后用同一个令牌,证明令牌已经落盘
// 身份与令牌:Agent 记录在,管理员令牌仍然有效
JsonNode agents = await _backend.GetJsonAsync(admin, "/api/admin/agents");
JsonNode restored = agents["items"]!.AsArray().Single(a => a!["id"]!.GetValue<string>() == id)!;
Assert.Equal("active", restored["status"]!.GetValue<string>());
Assert.False(restored["online"]!.GetValue<bool>());
// 持久层:重启前算好的分钟窗口原样还在
JsonNode after = await _backend.GetJsonAsync(admin, url + "&step=1m");
Assert.Equal(before["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), after["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 9);
Assert.Equal(25.0, after["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
// 热层:重启后为空,回填之后才会被查询选用;回填完成后 1s 步长能读到同样的数据
JsonNode hot = null!;
await BackendFixture.EventuallyAsync(async () =>
{
hot = await _backend.GetJsonAsync(admin, url + "&step=1s");
return hot["source"]!.GetValue<string>() == "hot" && hot["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 1200;
});
Assert.Equal(25.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
// 同一个身份可以重新连接,并拿回当前版本的期望状态
await using AgentSimulator again = await _backend.ReconnectAsync(agent);
Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
// 已经落盘的块在重启后仍然可以按 ID 下载
JsonNode chunks = await _backend.GetJsonAsync(admin, $"/api/admin/raw/chunks?agent_id={id}&limit=1");
string chunkId = chunks["items"]![0]!["chunk_id"]!.GetValue<string>();
using HttpResponseMessage download = await admin.GetAsync("/api/admin/raw/chunks/" + chunkId);
Assert.True(RawChunkCodec.Decode(await download.Content.ReadAsByteArrayAsync()).IsSuccess);
}
}