chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,35 @@
namespace Monitor.Protocol;
/// <summary>CRC-32C(Castagnoli),RawChunk 的完整性校验。</summary>
public static class Crc32c
{
private static readonly uint[] s_table = BuildTable();
public static uint Compute(ReadOnlySpan<byte> data)
{
uint crc = uint.MaxValue;
foreach (byte b in data)
{
crc = s_table[(crc ^ b) & 0xFF] ^ (crc >> 8);
}
return ~crc;
}
private static uint[] BuildTable()
{
var table = new uint[256];
for (uint i = 0; i < 256; i++)
{
uint c = i;
for (int k = 0; k < 8; k++)
{
c = (c & 1) != 0 ? (c >> 1) ^ 0x82F63B78u : c >> 1;
}
table[i] = c;
}
return table;
}
}
@@ -0,0 +1,210 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary>
public sealed class ChunkClock
{
[JsonPropertyName("agent_mono_us")]
public long AgentMonoUs { get; set; }
[JsonPropertyName("utc_ms")]
public long UtcMs { get; set; }
/// <summary>Agent 时钟 − Probe 时钟(微秒)。</summary>
[JsonPropertyName("probe_offset_us")]
public long ProbeOffsetUs { get; set; }
/// <summary>Probe 相对 Agent 的时钟漂移(ppb,十亿分之一)。</summary>
[JsonPropertyName("probe_drift_ppb")]
public long ProbeDriftPpb { get; set; }
}
public sealed class ColumnHeader
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>u8 | u16 | u32 | u64 | i32 | i64</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "";
/// <summary>plain | delta</summary>
[JsonPropertyName("enc")]
public string Enc { get; set; } = "plain";
[JsonPropertyName("nullable")]
public bool Nullable { get; set; }
/// <summary>ts_probe | ts_agent | ts_local,其他为空。</summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
}
public sealed class StreamHeader
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("count")]
public int Count { get; set; }
[JsonPropertyName("columns")]
public List<ColumnHeader> Columns { get; set; } = [];
}
/// <summary>块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。</summary>
public sealed class RawChunkHeader
{
[JsonPropertyName("chunk_id")]
public ChunkId ChunkId { get; set; }
[JsonPropertyName("container_version")]
public int ContainerVersion { get; set; } = ProtocolVersion.Container;
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("task_id")]
public TaskId? TaskId { get; set; }
[JsonPropertyName("test_id")]
public TestId? TestId { get; set; }
[JsonPropertyName("phase")]
public int? Phase { get; set; }
[JsonPropertyName("agent_id")]
public NodeId AgentId { get; set; }
[JsonPropertyName("probe_id")]
public ProbeId? ProbeId { get; set; }
[JsonPropertyName("target")]
public string? Target { get; set; }
/// <summary>参数的完整快照(包含默认值)。</summary>
[JsonPropertyName("params")]
public Dictionary<string, JsonElement> Params { get; set; } = [];
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("direction")]
public string? Direction { get; set; }
[JsonPropertyName("start_utc_ms")]
public long StartUtcMs { get; set; }
[JsonPropertyName("duration_ms")]
public int DurationMs { get; set; }
/// <summary>Probe 一侧的列没有在宽限期内送达:只有 Agent 一侧的数据。</summary>
[JsonPropertyName("partial")]
public bool Partial { get; set; }
[JsonPropertyName("clock")]
public ChunkClock Clock { get; set; } = new();
[JsonPropertyName("streams")]
public List<StreamHeader> Streams { get; set; } = [];
/// <summary>
/// "line:&lt;probe_id&gt;" 或 "target:&lt;task_id&gt;":存储和分析用的序列标识。
/// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。
/// </summary>
[JsonIgnore]
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : LinePrefix + (ProbeId?.Value ?? "");
public const string LinePrefix = "line:";
}
public sealed class RawColumn
{
public RawColumn(string name, ColumnType type, ColumnEnc enc, bool nullable, ColumnRole role, long[] values, bool[]? valid)
{
Name = name;
Type = type;
Enc = enc;
Nullable = nullable;
Role = role;
Values = values;
Valid = valid;
}
public string Name { get; }
public ColumnType Type { get; }
public ColumnEnc Enc { get; }
public bool Nullable { get; }
public ColumnRole Role { get; }
/// <summary>所有列统一用 long 存放(u64 按位保存)。缺失值对应的位置内容无意义,请先看 <see cref="IsValid"/>。</summary>
public long[] Values { get; }
public bool[]? Valid { get; }
public int Count => Values.Length;
public bool IsValid(int row) => Valid is null || Valid[row];
}
public sealed class RawStream
{
public RawStream(string name, int count, IReadOnlyList<RawColumn> columns)
{
Name = name;
Count = count;
Columns = columns;
}
public string Name { get; }
public int Count { get; }
public IReadOnlyList<RawColumn> Columns { get; }
public RawColumn? Find(string name)
{
foreach (RawColumn column in Columns)
{
if (column.Name == name)
{
return column;
}
}
return null;
}
}
public sealed class RawChunk
{
public RawChunk(RawChunkHeader header, IReadOnlyList<RawStream> streams)
{
Header = header;
Streams = streams;
}
public RawChunkHeader Header { get; }
public IReadOnlyList<RawStream> Streams { get; }
public RawStream? Find(string name)
{
foreach (RawStream stream in Streams)
{
if (stream.Name == name)
{
return stream;
}
}
return null;
}
}
@@ -0,0 +1,345 @@
using System.Buffers.Binary;
using System.IO.Compression;
using System.Text.Json;
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>
/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。
/// 布局:<c>"MNRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )</c>。
/// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。
/// </summary>
public static class RawChunkCodec
{
private const int PrefixBytes = 4 + 1 + 4;
private static ReadOnlySpan<byte> Magic => "MNRC"u8;
/// <summary>压缩之前的正文:<c>u32 header_len | header JSON | 各列数据</c>。布局变化时黄金字节测试会失败,提醒升级版本。</summary>
public static byte[] EncodeBody(RawChunk chunk)
{
RawChunkHeader header = chunk.Header;
header.ContainerVersion = ProtocolVersion.Container;
header.Streams = [];
foreach (RawStream stream in chunk.Streams)
{
var streamHeader = new StreamHeader { Name = stream.Name, Count = stream.Count };
foreach (RawColumn column in stream.Columns)
{
streamHeader.Columns.Add(new ColumnHeader
{
Name = column.Name,
Type = TypeName(column.Type),
Enc = column.Enc == ColumnEnc.Delta ? "delta" : "plain",
Nullable = column.Nullable,
Role = RoleName(column.Role),
});
}
header.Streams.Add(streamHeader);
}
byte[] headerJson = JsonSerializer.SerializeToUtf8Bytes(header, MonitorJsonContext.Default.RawChunkHeader);
using var body = new MemoryStream();
Span<byte> length = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(length, (uint)headerJson.Length);
body.Write(length);
body.Write(headerJson);
foreach (RawStream stream in chunk.Streams)
{
foreach (RawColumn column in stream.Columns)
{
WriteColumn(body, column);
}
}
return body.ToArray();
}
public static byte[] Encode(RawChunk chunk)
{
byte[] plain = EncodeBody(chunk);
using var compressed = new MemoryStream();
using (var brotli = new BrotliStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
{
brotli.Write(plain);
}
byte[] payload = compressed.ToArray();
byte[] result = new byte[PrefixBytes + payload.Length];
Magic.CopyTo(result);
result[4] = ProtocolVersion.Container;
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload));
payload.CopyTo(result, PrefixBytes);
return result;
}
public static Result<RawChunk> Decode(ReadOnlySpan<byte> data)
{
if (data.Length < PrefixBytes || !data[..4].SequenceEqual(Magic))
{
return Corrupt("bad magic");
}
if (data[4] != ProtocolVersion.Container)
{
return Corrupt($"unsupported container version {data[4]}");
}
ReadOnlySpan<byte> payload = data[PrefixBytes..];
if (BinaryPrimitives.ReadUInt32LittleEndian(data[5..]) != Crc32c.Compute(payload))
{
return Corrupt("crc mismatch");
}
byte[]? plain = Inflate(payload);
if (plain is null)
{
return Corrupt("invalid or oversized compressed data");
}
if (plain.Length < 4)
{
return Corrupt("truncated header length");
}
uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(plain);
if (headerLength > plain.Length - 4)
{
return Corrupt("header length exceeds data");
}
RawChunkHeader? header;
try
{
header = JsonSerializer.Deserialize(plain.AsSpan(4, (int)headerLength), MonitorJsonContext.Default.RawChunkHeader);
}
catch (JsonException)
{
return Corrupt("header is not valid json");
}
if (header is null || header.Streams.Count > ProtocolLimits.MaxStreamsPerChunk)
{
return Corrupt("header is missing or has too many streams");
}
ReadOnlySpan<byte> columns = plain.AsSpan(4 + (int)headerLength);
var streams = new List<RawStream>(header.Streams.Count);
foreach (StreamHeader streamHeader in header.Streams)
{
if (streamHeader.Count is < 0 or > ProtocolLimits.MaxRecordsPerChunk || streamHeader.Columns.Count > ProtocolLimits.MaxColumnsPerStream)
{
return Corrupt($"stream {streamHeader.Name} has an invalid size");
}
var cols = new List<RawColumn>(streamHeader.Columns.Count);
foreach (ColumnHeader columnHeader in streamHeader.Columns)
{
if (!TryParseType(columnHeader.Type, out ColumnType type) || !TryParseEnc(columnHeader.Enc, out ColumnEnc enc))
{
return Corrupt($"column {columnHeader.Name} has an unknown type or encoding");
}
RawColumn? column = ReadColumn(ref columns, columnHeader, type, enc, streamHeader.Count);
if (column is null)
{
return Corrupt($"column {streamHeader.Name}.{columnHeader.Name} is truncated or out of range");
}
cols.Add(column);
}
streams.Add(new RawStream(streamHeader.Name, streamHeader.Count, cols));
}
return columns.Length == 0 ? Result<RawChunk>.Ok(new RawChunk(header, streams)) : Corrupt("trailing bytes");
}
private static Result<RawChunk> Corrupt(string message) =>
Result<RawChunk>.Fail(MonitorErrors.RawCorrupt, message);
private static byte[]? Inflate(ReadOnlySpan<byte> payload)
{
using var input = new MemoryStream(payload.ToArray());
using var brotli = new BrotliStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
try
{
int read;
while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, read);
if (output.Length > ProtocolLimits.MaxDecodedChunkBytes)
{
return null;
}
}
}
catch (InvalidDataException)
{
return null;
}
return output.ToArray();
}
private static void WriteColumn(MemoryStream body, RawColumn column)
{
int count = column.Count;
if (column.Nullable)
{
byte[] bitmap = new byte[(count + 7) / 8];
for (int i = 0; i < count; i++)
{
if (column.IsValid(i))
{
bitmap[i >> 3] |= (byte)(1 << (i & 7));
}
}
body.Write(bitmap);
}
bool signed = column.Type is ColumnType.I32 or ColumnType.I64;
long previous = 0;
for (int i = 0; i < count; i++)
{
if (!column.IsValid(i))
{
continue;
}
long value = column.Values[i];
if (column.Enc == ColumnEnc.Delta)
{
VarInt.Write(body, VarInt.ZigZag(value - previous));
previous = value;
}
else
{
VarInt.Write(body, signed ? VarInt.ZigZag(value) : (ulong)value);
}
}
}
private static RawColumn? ReadColumn(ref ReadOnlySpan<byte> data, ColumnHeader header, ColumnType type, ColumnEnc enc, int count)
{
bool[]? valid = null;
if (header.Nullable)
{
int bitmapBytes = (count + 7) / 8;
if (data.Length < bitmapBytes)
{
return null;
}
valid = new bool[count];
for (int i = 0; i < count; i++)
{
valid[i] = (data[i >> 3] & (1 << (i & 7))) != 0;
}
data = data[bitmapBytes..];
}
bool signed = type is ColumnType.I32 or ColumnType.I64;
long[] values = new long[count];
long previous = 0;
for (int i = 0; i < count; i++)
{
if (valid is not null && !valid[i])
{
continue;
}
if (!VarInt.TryRead(data, out ulong raw, out int consumed))
{
return null;
}
data = data[consumed..];
long value;
if (enc == ColumnEnc.Delta)
{
value = previous + VarInt.UnZigZag(raw);
previous = value;
}
else
{
value = signed ? VarInt.UnZigZag(raw) : unchecked((long)raw);
}
if (!InRange(type, value))
{
return null;
}
values[i] = value;
}
return new RawColumn(header.Name, type, enc, header.Nullable, ParseRole(header.Role), values, valid);
}
private static bool InRange(ColumnType type, long value) => type switch
{
ColumnType.U8 => value is >= 0 and <= byte.MaxValue,
ColumnType.U16 => value is >= 0 and <= ushort.MaxValue,
ColumnType.U32 => value is >= 0 and <= uint.MaxValue,
ColumnType.I32 => value is >= int.MinValue and <= int.MaxValue,
_ => true,
};
public static string TypeName(ColumnType type) => type switch
{
ColumnType.U8 => "u8",
ColumnType.U16 => "u16",
ColumnType.U32 => "u32",
ColumnType.U64 => "u64",
ColumnType.I32 => "i32",
_ => "i64",
};
private static bool TryParseType(string text, out ColumnType type)
{
switch (text)
{
case "u8": type = ColumnType.U8; return true;
case "u16": type = ColumnType.U16; return true;
case "u32": type = ColumnType.U32; return true;
case "u64": type = ColumnType.U64; return true;
case "i32": type = ColumnType.I32; return true;
case "i64": type = ColumnType.I64; return true;
default: type = default; return false;
}
}
private static bool TryParseEnc(string text, out ColumnEnc enc)
{
switch (text)
{
case "plain": enc = ColumnEnc.Plain; return true;
case "delta": enc = ColumnEnc.Delta; return true;
default: enc = default; return false;
}
}
private static string? RoleName(ColumnRole role) => role switch
{
ColumnRole.TsProbe => "ts_probe",
ColumnRole.TsAgent => "ts_agent",
ColumnRole.TsLocal => "ts_local",
_ => null,
};
private static ColumnRole ParseRole(string? text) => text switch
{
"ts_probe" => ColumnRole.TsProbe,
"ts_agent" => ColumnRole.TsAgent,
"ts_local" => ColumnRole.TsLocal,
_ => ColumnRole.None,
};
}
@@ -0,0 +1,58 @@
namespace Monitor.Protocol;
/// <summary>按 <see cref="StreamSpec"/> 逐行构造一个 <see cref="RawStream"/>(用于测试、模拟器和离线工具,不用于节点热路径)。</summary>
public sealed class StreamBuilder
{
private readonly StreamSpec _spec;
private readonly List<long>[] _values;
private readonly List<bool>[] _valid;
public StreamBuilder(StreamSpec spec)
{
_spec = spec;
_values = new List<long>[spec.Columns.Count];
_valid = new List<bool>[spec.Columns.Count];
for (int i = 0; i < _values.Length; i++)
{
_values[i] = [];
_valid[i] = [];
}
}
public int Count { get; private set; }
/// <summary>添加一行;<c>null</c> 表示缺失值(只允许用于可空列)。</summary>
public StreamBuilder Add(params long?[] row)
{
if (row.Length != _values.Length)
{
throw new ArgumentException($"stream {_spec.Name} expects {_values.Length} values, got {row.Length}", nameof(row));
}
for (int i = 0; i < row.Length; i++)
{
if (row[i] is null && !_spec.Columns[i].Nullable)
{
throw new ArgumentException($"column {_spec.Columns[i].Name} is not nullable", nameof(row));
}
_values[i].Add(row[i] ?? 0);
_valid[i].Add(row[i] is not null);
}
Count++;
return this;
}
public RawStream Build()
{
var columns = new List<RawColumn>(_values.Length);
for (int i = 0; i < _values.Length; i++)
{
ColumnSpec spec = _spec.Columns[i];
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. _values[i]], spec.Nullable ? [.. _valid[i]] : null));
}
return new RawStream(_spec.Name, Count, columns);
}
}