agent: unify listening and dialing with directed mutual measurements

Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
This commit is contained in:
justme committed 2026-10-21 11:55:36 +08:00
1 parent d5ebf29e57
commit ad5efc62ff
206 files changed
+4202 -4964

No files matched your search

@@ -1,14 +1,15 @@
using Fleet.Core;
using Fleet.Server;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>线路按(Agent,Probe,地址族)隔离。IPv4 保留 <c>agent~probe</c> 和 <c>line:probe</c>;IPv6 分别追加 <c>~ipv6</c>、<c>:ipv6</c>。</summary>
public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVersion = IpVersions.V4)
/// <summary>线路按(监听方,拨号方,地址族)隔离。IPv4 是 <c>listener~dialer</c> 和 <c>line:dialer</c>;IPv6 分别追加 <c>~ipv6</c>、<c>:ipv6</c>。</summary>
public readonly record struct LineKey(NodeId Listener, NodeId Dialer, string IpVersion = IpVersions.V4)
{
public string Series => IpVersions.LineSeries(Probe.Value, IpVersion);
public string Series => IpVersions.LineSeries(Dialer.Value, IpVersion);
public string Id => Agent.Value + "~" + Probe.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : "");
public string Id => Listener.Value + "~" + Dialer.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : "");
public static bool TryParse(string text, out LineKey key)
{
@@ -19,52 +20,49 @@ public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVers
return false;
}
key = new LineKey(new NodeId(parts[0]), new ProbeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4);
key = new LineKey(new NodeId(parts[0]), new NodeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4);
return true;
}
}
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4, ProbeQuicLinkSnapshot? QuicHealth = null)
/// <summary>一条线路的展示视图:监听方的状态、拨号方记录,以及这一对现在是否连着。</summary>
public sealed record LineView(AgentView Listener, AgentRecord Dialer, string IpVersion = IpVersions.V4, ReportedLink? Link = null)
{
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion);
public LineKey Key => new(Listener.Agent.Id, Dialer.Id, IpVersion);
public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id && p.IpVersion == IpVersion);
public bool Connected => Listener.Dialers.Any(d => d.Id == Dialer.Id.Value && d.IpVersion == IpVersion);
public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id && p.IpVersion == IpVersion);
public ConnectedDialerInfo? Connection => Listener.Dialers.FirstOrDefault(d => d.Id == Dialer.Id.Value && d.IpVersion == IpVersion);
}
/// <summary>线路目录:把 id 解析成(Agent,Probe),列出所有线路。</summary>
/// <summary>线路目录:把 id 解析成(监听方,拨号方),列出所有线路。</summary>
public sealed class LineService
{
private readonly AgentService _agents;
private readonly IProbeStore _probes;
private readonly LinkService _links;
private readonly ProbeQuicRuntime _quicRuntime;
private readonly LinkRuntime _linkRuntime;
public LineService(AgentService agents, IProbeStore probes, LinkService links, ProbeQuicRuntime quicRuntime)
public LineService(AgentService agents, LinkService links, LinkRuntime linkRuntime)
{
_links = links;
_agents = agents;
_probes = probes;
_quicRuntime = quicRuntime;
_linkRuntime = linkRuntime;
}
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
/// <summary>所有线路:每个监听节点和每个激活的节点配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
public async ValueTask<IReadOnlyList<LineView>> ListAsync(Func<AgentView, bool> include, CancellationToken ct)
{
IReadOnlyList<AgentView> agents = await _agents.ListAsync(ct);
IReadOnlyList<ProbeRecord> probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)];
IReadOnlyList<AgentView> nodes = await _agents.ListAsync(ct);
LinkSnapshot links = await _links.SnapshotAsync(ct);
var result = new List<LineView>();
foreach (AgentView agent in agents.Where(include))
foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && include(n)))
{
foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value)))
foreach (AgentRecord dialer in nodes.Select(n => n.Agent).Where(a => a.Status == NodeStatus.Active && links.IsLinked(a, listener.Agent)))
{
result.Add(new LineView(agent, probe, IpVersions.V4, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V4)));
if (agent.Agent.Ipv6Host.Length > 0)
result.Add(new LineView(listener, dialer, IpVersions.V4, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, IpVersions.V4)));
if (listener.Agent.Ipv6Host.Length > 0)
{
result.Add(new LineView(agent, probe, IpVersions.V6, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V6)));
result.Add(new LineView(listener, dialer, IpVersions.V6, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, IpVersions.V6)));
}
}
}
@@ -73,12 +71,12 @@ public sealed class LineService
}
/// <summary>
/// 线路 id 是 <c>agent~probe</c>。只给 Agent id 时:恰好有一个未吊销的 Probe 就用它,否则要求明确指定(<c>probe_required</c>)。
/// 线路 id 是 <c>listener~dialer</c>。只给监听方 id 时:当前恰好有一条开着的线路就用它,否则要求明确指定(<c>dialer_required</c>)。
/// </summary>
public async ValueTask<Result<LineView>> ResolveAsync(string id, CancellationToken ct)
{
string agentPart = id;
string? probePart = null;
string listenerPart = id;
string? dialerPart = null;
string ipVersion = IpVersions.V4;
if (id.Contains('~', StringComparison.Ordinal))
{
@@ -87,38 +85,38 @@ public sealed class LineService
return Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "line not found");
}
agentPart = key.Agent.Value;
probePart = key.Probe.Value;
listenerPart = key.Listener.Value;
dialerPart = key.Dialer.Value;
ipVersion = key.IpVersion;
}
AgentView? agent = await _agents.GetAsync(new NodeId(agentPart), ct);
if (agent is null)
AgentView? listener = await _agents.GetAsync(new NodeId(listenerPart), ct);
if (listener is null)
{
return Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "line not found");
}
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
ProbeRecord? probe;
if (probePart is not null)
AgentRecord? dialer;
if (dialerPart is not null)
{
probe = probes.FirstOrDefault(p => p.Id == probePart);
if (probe is null)
dialer = (await _agents.ListAsync(ct)).Select(n => n.Agent).FirstOrDefault(a => a.Id.Value == dialerPart);
if (dialer is null)
{
return Result<LineView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
return Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "dialer not found");
}
}
else
{
ProbeRecord[] active = [.. probes.Where(p => p.RevokedAtMs is null)];
if (active.Length != 1)
LinkSnapshot links = await _links.SnapshotAsync(ct);
AgentRecord[] linked = [.. (await _agents.ListAsync(ct)).Select(n => n.Agent).Where(a => a.Status == NodeStatus.Active && links.IsLinked(a, listener.Agent))];
if (linked.Length != 1)
{
return Result<LineView>.Fail(MoShiroErrors.ProbeRequired, active.Length == 0 ? "there is no active probe" : "several probes exist: use <agent_id>~<probe_id> as the line id");
return Result<LineView>.Fail(MoShiroErrors.DialerRequired, linked.Length == 0 ? "there is no line to this node" : "several lines exist: use <listener_id>~<dialer_id> as the line id");
}
probe = active[0];
dialer = linked[0];
}
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, ipVersion)));
return Result<LineView>.Ok(new LineView(listener, dialer, ipVersion, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, ipVersion)));
}
}
@@ -58,9 +58,9 @@ public sealed class QueryService
// ───────────────────────── 单条线路的时序 ─────────────────────────
/// <summary>这条线路现在是不是连着(Agent 报告了这个 Probe 的会话):连着的线路窗口以当前时间为终点,不去查“最后一个样本”——Backend 刚重启、热层和持久层还没追上时,后者会落后好几个小时,页面就显示成旧数据。</summary>
/// <summary>这条线路现在是不是连着(监听方报告了这个拨号方的会话):连着的线路窗口以当前时间为终点,不去查“最后一个样本”——Backend 刚重启、热层和持久层还没追上时,后者会落后好几个小时,页面就显示成旧数据。</summary>
private bool IsConnected(LineKey line) =>
_runtime.TryGet(line.Agent, out AgentRuntimeState? state) && state.Probes.Any(p => p.Id == line.Probe.Value && p.IpVersion == line.IpVersion);
_runtime.TryGet(line.Listener, out AgentRuntimeState? state) && state.Dialers.Any(p => p.Id == line.Dialer.Value && p.IpVersion == line.IpVersion);
public async ValueTask<Result<SeriesResult>> SeriesAsync(LineKey line, RangeRequest request, MetricSelection selection, CancellationToken ct)
{
@@ -70,7 +70,7 @@ public sealed class QueryService
&& !IsConnected(line))
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (await _loader.LatestSampleMsAsync(line.Agent, line.Series, now, ct) is { } latest && latest < now - 5000)
if (await _loader.LatestSampleMsAsync(line.Listener, line.Series, now, ct) is { } latest && latest < now - 5000)
{
request = request with { Range = null, From = DateTimeOffset.FromUnixTimeMilliseconds(latest - span), To = DateTimeOffset.FromUnixTimeMilliseconds(latest) };
}
@@ -82,7 +82,7 @@ public sealed class QueryService
return Result<SeriesResult>.Fail(error);
}
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct);
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
{
return Result<SeriesResult>.Fail(error);
@@ -124,7 +124,7 @@ public sealed class QueryService
SummaryResult summary = Summarize(bucketed.Merge(), bucketed.TotalRows, p.Plan, p.Range, selection, overlay);
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
IReadOnlyList<EventRecord> events = await _events.ListAsync(line.Agent, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct);
IReadOnlyList<EventRecord> events = await _events.ListAsync(line.Listener, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct);
IReadOnlyList<GapInfo> gaps = FindGaps(bucketed, p.Range, nowMs, events);
var versions = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (string id in selection.ExportIds)
@@ -140,7 +140,7 @@ public sealed class QueryService
/// <summary>这条线路最早的数据(Unix 毫秒);没有数据返回 null。</summary>
public ValueTask<long?> EarliestAsync(LineKey line, CancellationToken ct) =>
_loader.EarliestSampleMsAsync(line.Agent, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
_loader.EarliestSampleMsAsync(line.Listener, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
// ───────────────────────── 首页排行 ─────────────────────────
@@ -174,7 +174,7 @@ public sealed class QueryService
}
IReadOnlyDictionary<string, double> overlay = selection.WantsScores ? await TestOverlayAsync(line.Key, ct) : new Dictionary<string, double>();
IReadOnlyList<TestRecord> recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, line.Key.IpVersion, ct);
IReadOnlyList<TestRecord> recent = await _tests.ListAsync(line.Key.Listener, line.Key.Dialer, 1, line.Key.IpVersion, ct);
result.Add(new OverviewLine(line, Summarize(merged, count, p.Plan, p.Range, selection, overlay), recent.Count > 0 ? recent[0] : null));
}
@@ -245,7 +245,7 @@ public sealed class QueryService
return Result<DistributionResult>.Fail(error);
}
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct);
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
{
return Result<DistributionResult>.Fail(error);
@@ -289,7 +289,7 @@ public sealed class QueryService
return Result<HeatmapResult>.Fail(error);
}
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct);
if (!loaded.TryGet(out IReadOnlyList<SourceRow>? rows, out error))
{
return Result<HeatmapResult>.Fail(error);
@@ -379,7 +379,7 @@ public sealed class QueryService
long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (7 * Steps.Day);
var overlay = new Dictionary<string, double>(StringComparer.Ordinal);
var seenPlans = new HashSet<string>();
foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, line.IpVersion, ct))
foreach (TestRecord test in await _tests.ListAsync(line.Listener, line.Dialer, 20, line.IpVersion, ct))
{
if (test.Status != TestStatuses.Succeeded || test.FinishedAtMs is not { } finished || finished < cutoff || !seenPlans.Add(test.Plan))
{
@@ -422,7 +422,7 @@ public sealed class QueryService
{
long from = bucketed.T[start];
long to = bucketed.T[i - 1] + range.StepMs;
EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "probe_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated");
EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "dialer_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated");
gaps.Add(new GapInfo(from, to, near?.Kind ?? "no_data"));
}
}
@@ -130,7 +130,7 @@ public sealed class RowLoader
{
foreach (LineKey line in lines)
{
Result<IReadOnlyList<SourceRow>> one = await LoadAsync(plan, range, line.Agent, line.Series, ct);
Result<IReadOnlyList<SourceRow>> one = await LoadAsync(plan, range, line.Listener, line.Series, ct);
if (!one.TryGet(out IReadOnlyList<SourceRow>? rows, out FleetError? error))
{
return Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>.Fail(error);
@@ -142,14 +142,14 @@ public sealed class RowLoader
return Result<Dictionary<LineKey, IReadOnlyList<SourceRow>>>.Ok(result);
}
// 每个 Probe 一个序列:按序列分组,每组一次查询
// 每个拨号方一个序列:按序列分组,每组一次查询
foreach (IGrouping<string, LineKey> bySeries in lines.GroupBy(l => l.Series))
{
NodeId[] agents = [.. bySeries.Select(l => l.Agent).Distinct()];
NodeId[] agents = [.. bySeries.Select(l => l.Listener).Distinct()];
IReadOnlyList<MetricRow> all = await _metrics.ReadAgentsAsync(ToLevel(plan.Source), agents, bySeries.Key, range.FromMs, range.ToMs, ct);
foreach (IGrouping<NodeId, MetricRow> group in all.GroupBy(r => r.AgentId))
{
LineKey key = bySeries.First(l => l.Agent == group.Key);
LineKey key = bySeries.First(l => l.Listener == group.Key);
result[key] = Decode([.. group]);
}
}