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.
285 lines
11 KiB
C#
285 lines
11 KiB
C#
using System.Text.Json;
|
||
using Fleet.Hosting;
|
||
using Fleet.Core;
|
||
using Fleet.Rpc;
|
||
using MoShiro.Measurement;
|
||
using MoShiro.Protocol;
|
||
|
||
namespace MoShiro.Agent;
|
||
|
||
/// <summary>
|
||
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
|
||
/// 通过的线路任务<b>为每个拨号方会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个拨号方;
|
||
/// 通过的目标任务在本地运行,与拨号方无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
|
||
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个拨号方会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
|
||
/// </summary>
|
||
public sealed class TaskManager
|
||
{
|
||
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
|
||
private static readonly HashSet<string> s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)];
|
||
private static readonly HashSet<string> s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
|
||
|
||
private readonly object _gate = new();
|
||
private readonly Func<BudgetGuard> _guardFactory;
|
||
private readonly StreamRegistry _streams;
|
||
private readonly BlockEnv _env;
|
||
private readonly TargetRunners _targets;
|
||
private readonly Func<IReadOnlyList<int>> _quicPorts;
|
||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||
private readonly TrustAnchor _trust;
|
||
private readonly bool _acceptLineTasks;
|
||
private readonly Dictionary<DialerSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
|
||
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
|
||
private long _version;
|
||
private BudgetGuard? _guard;
|
||
|
||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null, bool acceptLineTasks = true)
|
||
{
|
||
_trust = trust ?? TrustAnchor.None;
|
||
_acceptLineTasks = acceptLineTasks;
|
||
_guardFactory = guardFactory;
|
||
_streams = streams;
|
||
_env = env;
|
||
_targets = targets;
|
||
_quicPorts = quicPorts;
|
||
_tcpPorts = tcpPorts;
|
||
}
|
||
|
||
public BudgetGuard Guard => _guard ??= _guardFactory();
|
||
|
||
/// <summary>某个拨号方的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个拨号方。</summary>
|
||
public Action<DialerSession, LineTaskSet>? OnLineTasksChanged { get; set; }
|
||
|
||
/// <summary>允许连接的拨号方变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
|
||
public Action<IReadOnlySet<string>>? OnPeersChanged { get; set; }
|
||
|
||
/// <summary>点对点握手时查找拨号方的公钥(SPKI)。</summary>
|
||
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
|
||
|
||
/// <summary>名册里全部拨号方的公钥:敲门关卡用它们派生配对密钥。</summary>
|
||
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
|
||
|
||
/// <summary>一个新的拨号方会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
|
||
public LineTaskSet OpenSession(DialerSession dialer)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
var entries = new Dictionary<string, LineEntry>();
|
||
foreach ((string id, (TaskSpec task, TaskDecision decision)) in _lineSpecs)
|
||
{
|
||
LineEntry entry = BuildLineEntry(task, decision, dialer);
|
||
Start(entry);
|
||
entries[id] = entry;
|
||
}
|
||
|
||
_sessionLines[dialer] = entries;
|
||
return SetFor(entries);
|
||
}
|
||
}
|
||
|
||
/// <summary>拨号方 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
|
||
public async Task CloseSessionAsync(DialerSession dialer)
|
||
{
|
||
List<LineEntry> entries;
|
||
lock (_gate)
|
||
{
|
||
if (!_sessionLines.Remove(dialer, out Dictionary<string, LineEntry>? lines))
|
||
{
|
||
return;
|
||
}
|
||
|
||
entries = [.. lines.Values];
|
||
}
|
||
|
||
foreach (LineEntry entry in entries)
|
||
{
|
||
await Retire(entry);
|
||
}
|
||
}
|
||
|
||
public async ValueTask<StateSyncResult> ApplyAsync(long version, TaskSet set, CancellationToken ct)
|
||
{
|
||
_ = ct;
|
||
var result = new StateSyncResult { AppliedVersion = version };
|
||
|
||
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
|
||
_trust.Update(set.TrustChain);
|
||
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||
foreach (PeerSpec peer in set.Peers)
|
||
{
|
||
try
|
||
{
|
||
peers[peer.NodeId.Value] = Convert.FromBase64String(peer.PublicKey);
|
||
}
|
||
catch (FormatException)
|
||
{
|
||
result.Rejected.Add(new RejectedItem { Id = peer.NodeId.Value, Reason = RejectReasons.InvalidParams });
|
||
}
|
||
}
|
||
|
||
_peers = peers;
|
||
|
||
BudgetGuard guard = Guard;
|
||
double usedMbps = 0;
|
||
long usedPps = 0;
|
||
var line = new Dictionary<string, (TaskSpec Task, TaskDecision Decision)>();
|
||
var targets = new List<(TaskSpec Spec, TaskDecision Decision)>();
|
||
|
||
foreach (TaskSpec task in set.Tasks)
|
||
{
|
||
if (!task.Enabled)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : _acceptLineTasks && s_lineKinds.Contains(task.Kind);
|
||
TaskDecision decision = shapeOk ? guard.Check(task, ref usedMbps, ref usedPps) : TaskDecision.Reject(RejectReasons.Unsupported);
|
||
if (!decision.Accepted)
|
||
{
|
||
result.Rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = decision.Reason! });
|
||
continue;
|
||
}
|
||
|
||
if (task.Scope == "target")
|
||
{
|
||
targets.Add((task, decision));
|
||
}
|
||
else
|
||
{
|
||
line[task.Id.Value] = (task, decision);
|
||
}
|
||
}
|
||
|
||
_targets.Reconcile(targets);
|
||
|
||
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
|
||
var retired = new List<LineEntry>();
|
||
var changes = new List<(DialerSession Dialer, LineTaskSet Set)>();
|
||
lock (_gate)
|
||
{
|
||
_lineSpecs = line;
|
||
_version = version;
|
||
foreach ((DialerSession dialer, Dictionary<string, LineEntry> current) in _sessionLines)
|
||
{
|
||
var next = new Dictionary<string, LineEntry>();
|
||
bool lineTasksChanged = current.Count != line.Count;
|
||
foreach ((string id, (TaskSpec task, TaskDecision decision)) in line)
|
||
{
|
||
LineEntry candidate = BuildLineEntry(task, decision, dialer);
|
||
if (current.TryGetValue(id, out LineEntry? existing) && existing.Signature == candidate.Signature)
|
||
{
|
||
next[id] = existing;
|
||
}
|
||
else
|
||
{
|
||
lineTasksChanged = true;
|
||
Start(candidate);
|
||
next[id] = candidate;
|
||
}
|
||
}
|
||
|
||
foreach ((string id, LineEntry entry) in current)
|
||
{
|
||
if (!next.TryGetValue(id, out LineEntry? kept) || !ReferenceEquals(kept, entry))
|
||
{
|
||
retired.Add(entry);
|
||
}
|
||
}
|
||
|
||
current.Clear();
|
||
foreach ((string id, LineEntry entry) in next)
|
||
{
|
||
current[id] = entry;
|
||
}
|
||
|
||
// The Agent receives a full task snapshot for peer/trust changes and target-only tasks too.
|
||
// Those changes do not affect a 拨号方's line blocks, so avoid retransmitting the same full
|
||
// line_task_set to every 拨号方. New sessions still get their initial snapshot in OpenSession.
|
||
if (lineTasksChanged)
|
||
{
|
||
changes.Add((dialer, SetFor(current)));
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (LineEntry entry in retired)
|
||
{
|
||
await Retire(entry);
|
||
}
|
||
|
||
OnPeersChanged?.Invoke(new HashSet<string>(peers.Keys, StringComparer.Ordinal));
|
||
foreach ((DialerSession dialer, LineTaskSet next) in changes)
|
||
{
|
||
OnLineTasksChanged?.Invoke(dialer, next);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>停止时释放所有拨号方会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
|
||
public async Task StopAsync()
|
||
{
|
||
_targets.Reconcile([]);
|
||
List<LineEntry> entries;
|
||
lock (_gate)
|
||
{
|
||
entries = [.. _sessionLines.Values.SelectMany(l => l.Values)];
|
||
_sessionLines.Clear();
|
||
}
|
||
|
||
foreach (LineEntry entry in entries)
|
||
{
|
||
await Retire(entry);
|
||
}
|
||
}
|
||
|
||
private LineTaskSet SetFor(Dictionary<string, LineEntry> entries) =>
|
||
new()
|
||
{
|
||
Version = _version,
|
||
Tasks = [.. entries.Values.Select(e => e.Task)],
|
||
QuicPorts = [.. _quicPorts()],
|
||
TcpPorts = [.. _tcpPorts()],
|
||
};
|
||
|
||
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, DialerSession dialer)
|
||
{
|
||
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = dialer };
|
||
var lineTask = new LineTask
|
||
{
|
||
Id = task.Id,
|
||
Kind = task.Kind,
|
||
Params = new Dictionary<string, JsonElement>(decision.Resolved!.Values),
|
||
StreamId = info.Id,
|
||
Port = info.Port,
|
||
};
|
||
var spec = new BlockSpec(decision.Kind!, decision.Resolved, info.Id, info.Port, new FragmentScope(task.Id, null, null));
|
||
return new LineEntry(lineTask, info, spec, _env.WithSession(dialer));
|
||
}
|
||
|
||
/// <summary>在数据面登记这个流并启动它的监听方积木。</summary>
|
||
private void Start(LineEntry entry)
|
||
{
|
||
IBlock block = BlockCatalog.Find(entry.Stream.Kind)!.Listener!(entry.Env, entry.Spec);
|
||
entry.Stream.Running = RunningBlock.Start(block, entry.Signature);
|
||
_streams.Add(entry.Stream);
|
||
}
|
||
|
||
private async Task Retire(LineEntry entry)
|
||
{
|
||
_streams.Remove(entry.Stream.Id);
|
||
if (entry.Stream.Running is { } running)
|
||
{
|
||
await running.StopAsync();
|
||
}
|
||
|
||
entry.Stream.Owner?.ForgetStream(entry.Stream.Id);
|
||
}
|
||
|
||
private sealed record LineEntry(LineTask Task, StreamInfo Stream, BlockSpec Spec, BlockEnv Env)
|
||
{
|
||
public string Signature => Spec.ConfigSignature;
|
||
}
|
||
}
|