refactor!: rename the project from Monitor to MoShiro

Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
This commit is contained in:
justme committed 2026-10-07 01:49:04 +08:00
1 parent 17b35223aa
commit 83e5120377
309 files changed
+1505 -1499

No files matched your search

@@ -0,0 +1,248 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 数据面会话:<c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)。两端的数据面包都用它认证。
/// Agent 的会话还带重放窗口等状态,所以是可以继承的。
/// </summary>
public class DataSession
{
public DataSession(long sessionId, byte[] key)
{
SessionId = sessionId;
Key = key;
}
public long SessionId { get; }
public byte[] Key { get; }
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
public virtual IPEndPoint? UdpEndpoint => null;
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
}
public interface IDataSessions
{
DataSession? Find(long sessionId);
}
/// <summary>只有一个会话(拨号方:Probe 与某个 Agent 的会话)。</summary>
public sealed class SingleSession : IDataSessions
{
private readonly DataSession _session;
public SingleSession(DataSession session)
{
_session = session;
}
public DataSession? Find(long sessionId) => sessionId == _session.SessionId ? _session : null;
}
public enum Admission
{
Accepted,
Invalid,
RateLimited,
}
/// <summary>
/// 监听方的准入策略(安全边界):重放窗口、令牌桶限速、源地址学习。积木本身不含任何策略;
/// 拨号方不需要,只做 MAC 校验。任何一步失败都是静默丢弃并计数,不回复。
/// </summary>
public interface IAdmission
{
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
/// <summary>TCP 回显的一条消息(只限速)。</summary>
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
}
/// <summary>数据面计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。</summary>
public sealed class DataPlaneCounters
{
private long _packetsIn;
private long _packetsOut;
private long _invalid;
private long _rateLimited;
public long PacketsIn => Interlocked.Read(ref _packetsIn);
public long PacketsOut => Interlocked.Read(ref _packetsOut);
public long DroppedInvalid => Interlocked.Read(ref _invalid);
public long DroppedRateLimited => Interlocked.Read(ref _rateLimited);
public void PacketIn() => Interlocked.Increment(ref _packetsIn);
public void PacketOut() => Interlocked.Increment(ref _packetsOut);
public void DropInvalid() => Interlocked.Increment(ref _invalid);
public void DropRateLimited() => Interlocked.Increment(ref _rateLimited);
}
/// <summary>一个列块属于线路任务还是按需测试的某个阶段。</summary>
public sealed record FragmentScope(TaskId? Task, TestId? Test, int? Phase);
/// <summary>
/// 积木交出一侧的行的地方。Probe:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。
/// </summary>
public interface IFragmentSink
{
void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port);
}
public enum FlushMode
{
/// <summary>只交出已经稳定的行(回显类等过宽限期的)。</summary>
Settled,
/// <summary>交出全部(阶段结束或角色停止)。</summary>
All,
}
/// <summary>积木无法继续(例如监听方等不到对端的地址):由编排者决定整个测试怎么结束。</summary>
public sealed class BlockFaultException : Exception
{
public BlockFaultException(string message)
: base(message)
{
}
}
/// <summary>
/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方 = Probe,监听方 = Agent),写在同一个文件里,
/// 共用包的构造、校验、行的记录;它们只产生<b>原始列</b>,不做任何统计,也不知道上层的方案或协议。
/// </summary>
public interface IBlock
{
/// <summary>运行到取消(回显、保持)或到自己结束(单向数据流的发送端)。</summary>
Task RunAsync(CancellationToken ct);
/// <summary>
/// 拨号方把已完成的行切成列块交给 <see cref="IFragmentSink"/>,返回块数;监听方返回 0(它的行留在 <see cref="Records"/> 里等合并)。
/// <paramref name="peerProduced"/>:对端是行的主导方时,对端产生的行数(单向数据流的发送包数)。
/// </summary>
int Flush(FlushMode mode, long peerProduced = 0);
/// <summary>这一侧作为主导方产生的行数(发出的包数)。</summary>
long Produced { get; }
/// <summary>这一侧为合并保留的按键记录;没有则为 null。</summary>
RowStore? Records(string streamName);
}
/// <summary>运行一个积木所需的全部参数:Kind、已经校验并补全默认值的参数、流编号、端口、列块归属。</summary>
public sealed class BlockSpec
{
public BlockSpec(KindSpec kind, ParamValues parameters, uint streamId, int port, FragmentScope scope)
{
Kind = kind;
Params = parameters;
StreamId = streamId;
Port = port;
Scope = scope;
}
public KindSpec Kind { get; }
public ParamValues Params { get; }
public uint StreamId { get; }
public int Port { get; }
public FragmentScope Scope { get; }
public string KindId => Kind.Id;
public long Int(string name) => Params.GetInt(name);
public bool Bool(string name) => Params.GetBool(name);
/// <summary>参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。</summary>
public string ConfigSignature => KindId + "|" + Port + "|" +
string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
/// <summary>参数、端口或流编号变化时重启:Probe 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。</summary>
public string Signature => StreamId + "|" + ConfigSignature;
/// <summary>校验参数(补全默认值)后构造;Kind 不存在或参数非法返回 null。</summary>
public static BlockSpec? Create(string kindId, IReadOnlyDictionary<string, JsonElement> parameters, uint streamId, int port, FragmentScope scope)
{
KindSpec? kind = Kinds.Find(kindId);
if (kind is null)
{
return null;
}
ParamResolution resolved = ParamResolver.Resolve(kind.Params, parameters);
return resolved.IsValid ? new BlockSpec(kind, resolved.Values!, streamId, port, scope) : null;
}
}
/// <summary>积木运行的环境:时钟、套接字、对端地址、列块出口。拨号方和监听方各自组装自己用得到的部分。</summary>
public sealed class BlockEnv
{
public required MonoClock Clock { get; init; }
public required INodeLog Log { get; init; }
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
public required Func<DataSession?> Session { get; init; }
public UdpMux? Udp { get; init; }
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
public IAdmission? Admission { get; init; }
public DataPlaneCounters Counters { get; init; } = new();
/// <summary>监听方:接受 TCP 数据连接。</summary>
public TcpAcceptor? TcpListen { get; init; }
/// <summary>拨号方:对端(Agent,或目标任务的目标地址)。</summary>
public IPAddress? PeerAddress { get; init; }
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
public byte[]? PeerSpkiSha256 { get; init; }
/// <summary>拨号方:列块出口。</summary>
public IFragmentSink? Fragments { get; init; }
/// <summary>本地硬上限:批量传输(tcp_bulk)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。</summary>
public double MaxBulkMbps { get; init; } = 100;
/// <summary>本地硬上限:单向数据流接收缓冲的包速率上界。</summary>
public long MaxPacketsPerSecond { get; init; } = 20_000;
public TimeProvider Time => Clock.Time;
/// <summary>同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 Probe 会话各起一组监听方积木)。</summary>
public BlockEnv WithSession(DataSession session) =>
new()
{
Clock = Clock,
Log = Log,
Session = () => session,
Udp = Udp,
Admission = Admission,
Counters = Counters,
TcpListen = TcpListen,
PeerAddress = PeerAddress,
PeerSpkiSha256 = PeerSpkiSha256,
Fragments = Fragments,
MaxBulkMbps = MaxBulkMbps,
MaxPacketsPerSecond = MaxPacketsPerSecond,
};
}
@@ -0,0 +1,26 @@
namespace MoShiro.Measurement;
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。</summary>
public sealed class MonoClock
{
private readonly TimeProvider _time;
public MonoClock(TimeProvider time)
{
_time = time;
}
public TimeProvider Time => _time;
public long NowUs()
{
long ticks = _time.GetTimestamp();
// 先除后乘会丢精度,先乘再除在 64 位内不会溢出(频率通常是 1e9 或 1e7)
return (ticks / _time.TimestampFrequency * 1_000_000) + (ticks % _time.TimestampFrequency * 1_000_000 / _time.TimestampFrequency);
}
public ClockAnchor Anchor() => new(NowUs(), _time.GetUtcNow().ToUnixTimeMilliseconds());
/// <summary>单调时钟读数 <paramref name="thenUs"/> 对应的 UTC 毫秒(只用于块的粗略起点,精确时间由块头的时钟锚点换算)。</summary>
public long UtcMsAt(long thenUs) => _time.GetUtcNow().ToUnixTimeMilliseconds() - (Math.Max(0, NowUs() - thenUs) / 1000);
}
@@ -0,0 +1,239 @@
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲,
/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 Probe 的列块到达后按键合并;
/// Probe 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。
/// </summary>
public sealed class RowStore
{
private readonly object _gate = new();
private readonly StreamSpec _spec;
private readonly IReadOnlyList<ColumnSpec> _columns;
private readonly ColumnSpec? _keyColumn;
private readonly int _capacity;
private readonly long[] _keys;
private readonly long[][] _values;
private readonly bool[]?[] _valid;
private long _next;
private long _drained;
public RowStore(StreamSpec spec, Side owner, int capacity)
{
_spec = spec;
_columns = spec.ColumnsOf(owner);
_keyColumn = spec.Key is null ? null : spec.Columns.First(c => c.Name == spec.Key);
_capacity = Math.Max(16, capacity);
_keys = new long[_capacity];
Array.Fill(_keys, -1);
_values = new long[_columns.Count][];
_valid = new bool[]?[_columns.Count];
for (int i = 0; i < _columns.Count; i++)
{
_values[i] = new long[_capacity];
_valid[i] = _columns[i].Nullable ? new bool[_capacity] : null;
}
}
public string Name => _spec.Name;
/// <summary>下一个自动分配的键(也是已经追加的行数)。</summary>
public long NextKey
{
get
{
lock (_gate)
{
return _next;
}
}
}
public int ColumnIndex(string name)
{
for (int i = 0; i < _columns.Count; i++)
{
if (_columns[i].Name == name)
{
return i;
}
}
throw new ArgumentException($"column {name} is not recorded on this side of stream {_spec.Name}", nameof(name));
}
/// <summary>
/// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:Probe 重启之后序号从 0 重新开始,
/// 旧会话里同一个序号的 t2/t3 还留着的话,会被当成新会话里这个包"上行已经到达",真正的上行丢失就被算成了下行丢失。
/// </summary>
public void Clear()
{
lock (_gate)
{
Array.Fill(_keys, -1);
_next = 0;
_drained = 0;
}
}
/// <summary>取出上次取出之后追加的所有行(只用于两侧各自记录完整行的 union 流:每一行只交出一次)。</summary>
public RawStream Drain(bool includeKey)
{
lock (_gate)
{
RawStream rows = Cut(_drained, _next, includeKey);
_drained = _next;
return rows;
}
}
/// <summary>追加一行,键自动递增(从 0 开始,与 <c>seq</c> 一致)。</summary>
public long Append()
{
lock (_gate)
{
long key = _next++;
Claim(key);
return key;
}
}
public void Set(long key, int column, long value)
{
lock (_gate)
{
int slot = Claim(key);
_values[column][slot] = value;
if (_valid[column] is { } valid)
{
valid[slot] = true;
}
}
}
/// <summary>一次写入这一侧的所有列(顺序同 <see cref="StreamSpec.ColumnsOf"/>)。</summary>
public void SetRow(long key, ReadOnlySpan<long> values)
{
lock (_gate)
{
int slot = Claim(key);
for (int i = 0; i < _columns.Count; i++)
{
_values[i][slot] = values[i];
if (_valid[i] is { } valid)
{
valid[slot] = true;
}
}
}
}
public bool Contains(long key)
{
lock (_gate)
{
return key >= 0 && _keys[(int)(key % _capacity)] == key;
}
}
/// <summary>读一个值;行不存在或该列没有设置(可空列)返回 false。</summary>
public bool TryGet(long key, int column, out long value)
{
lock (_gate)
{
int slot = (int)(key % _capacity);
value = 0;
if (key < 0 || _keys[slot] != key || (_valid[column] is { } valid && !valid[slot]))
{
return false;
}
value = _values[column][slot];
return true;
}
}
public bool IsSet(long key, int column) => TryGet(key, column, out _);
/// <summary>
/// 取出键在 [<paramref name="fromKey"/>, <paramref name="toKey"/>) 内、仍然存在的行(按键升序),不删除。
/// 列按声明顺序排列;<paramref name="includeKey"/> 时带上连接键那一列。
/// </summary>
public RawStream Cut(long fromKey, long toKey, bool includeKey)
{
lock (_gate)
{
var slots = new List<int>();
var keys = new List<long>();
for (long key = Math.Max(0, fromKey); key < toKey; key++)
{
int slot = (int)(key % _capacity);
if (_keys[slot] == key)
{
slots.Add(slot);
keys.Add(key);
}
}
var columns = new List<RawColumn>();
foreach (ColumnSpec spec in _spec.Columns)
{
if (includeKey && _keyColumn is not null && spec.Name == _keyColumn.Name)
{
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. keys], null));
continue;
}
int index = -1;
for (int i = 0; i < _columns.Count; i++)
{
if (_columns[i].Name == spec.Name)
{
index = i;
}
}
if (index < 0)
{
continue;
}
long[] values = new long[slots.Count];
bool[]? valid = spec.Nullable ? new bool[slots.Count] : null;
for (int r = 0; r < slots.Count; r++)
{
values[r] = _values[index][slots[r]];
if (valid is not null)
{
valid[r] = _valid[index]![slots[r]];
}
}
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, values, valid));
}
return new RawStream(_spec.Name, slots.Count, columns);
}
}
private int Claim(long key)
{
int slot = (int)(key % _capacity);
if (_keys[slot] != key)
{
_keys[slot] = key;
for (int i = 0; i < _columns.Count; i++)
{
_values[i][slot] = 0;
if (_valid[i] is { } valid)
{
valid[slot] = false;
}
}
}
return slot;
}
}
@@ -0,0 +1,47 @@
namespace MoShiro.Measurement;
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary>
public sealed class RunningBlock : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private RunningBlock(IBlock block, string signature)
{
Block = block;
Signature = signature;
Completion = Task.CompletedTask;
}
public IBlock Block { get; }
/// <summary>参数、端口或流编号变化时调和器据此重启积木。</summary>
public string Signature { get; }
/// <summary>积木自己结束(单向数据流的发送端)或失败(<see cref="BlockFaultException"/>)时完成;被 <see cref="StopAsync"/> 停止时正常完成。</summary>
public Task Completion { get; private set; }
public static RunningBlock Start(IBlock block, string signature)
{
var running = new RunningBlock(block, signature);
running.Completion = Task.Run(() => block.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
/// <summary>停止积木并等它退出(最多 5 秒)。</summary>
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await Completion.WaitAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or BlockFaultException)
{
// 没有及时退出或者已经失败:不再等
}
Dispose();
}
public void Dispose() => _cts.Dispose();
}