513 lines
14 KiB
C#
513 lines
14 KiB
C#
using MoShiro.Protocol;
|
||
|
||
namespace MoShiro.Backend.Analysis;
|
||
|
||
public enum Dir
|
||
{
|
||
Up,
|
||
Down,
|
||
Total,
|
||
}
|
||
|
||
public enum GapOutput
|
||
{
|
||
Count,
|
||
MaxMs,
|
||
}
|
||
|
||
/// <summary>回显流(stream <c>msg</c>)的列:t1/t4 是 Probe 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。</summary>
|
||
internal readonly struct EchoColumns
|
||
{
|
||
public EchoColumns(RawStream stream)
|
||
{
|
||
Count = stream.Count;
|
||
Seq = stream.Find("seq");
|
||
T1 = stream.Find("t1");
|
||
T2 = stream.Find("t2");
|
||
T3 = stream.Find("t3");
|
||
T4 = stream.Find("t4");
|
||
}
|
||
|
||
public int Count { get; }
|
||
|
||
public RawColumn? Seq { get; }
|
||
|
||
public RawColumn? T1 { get; }
|
||
|
||
public RawColumn? T2 { get; }
|
||
|
||
public RawColumn? T3 { get; }
|
||
|
||
public RawColumn? T4 { get; }
|
||
|
||
public bool HasBasics => T1 is not null && T4 is not null;
|
||
|
||
/// <summary>行 i 的发送时刻(UTC 毫秒)。</summary>
|
||
public long SentUtcMs(ChunkView view, int i) => view.ColumnToUtcMs(T1!, T1!.Values[i]);
|
||
|
||
public bool IsUpLost(int i) => T2 is not null && !T2.IsValid(i);
|
||
|
||
public bool IsReplyLost(int i) => T4 is null || !T4.IsValid(i);
|
||
|
||
/// <summary>按到达时间(t4)排序后的行号,只含收到回复的行。</summary>
|
||
public int[] ArrivalOrder()
|
||
{
|
||
var rows = new List<int>(Count);
|
||
for (int i = 0; i < Count; i++)
|
||
{
|
||
if (!IsReplyLost(i))
|
||
{
|
||
rows.Add(i);
|
||
}
|
||
}
|
||
|
||
int[] order = [.. rows];
|
||
long[] keys = new long[order.Length];
|
||
for (int i = 0; i < order.Length; i++)
|
||
{
|
||
keys[i] = T4!.Values[order[i]];
|
||
}
|
||
|
||
Array.Sort(keys, order);
|
||
return order;
|
||
}
|
||
}
|
||
|
||
internal sealed class RttOp : MetricOp
|
||
{
|
||
public override string Signature => "rtt/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < c.Count; i++)
|
||
{
|
||
if (c.IsReplyLost(i) || !c.T1!.IsValid(i))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long rttUs = c.T4!.Values[i] - c.T1.Values[i];
|
||
if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i))
|
||
{
|
||
rttUs -= c.T3.Values[i] - c.T2.Values[i];
|
||
}
|
||
|
||
if (rttUs >= 0)
|
||
{
|
||
sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 长尾回显:每个有回复的回显一个观测(是否"慢")。阈值以同一个块(10 秒)里 RTT 的中位数为基线:
|
||
/// <c>max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))</c>。基线取自块内数据,所以每个观测只依赖自己所在的块,窗口合并仍然精确;
|
||
/// 块里的样本少于 <c>minSamples</c> 时基线不可靠,不产生观测。TCP 的长尾通常是丢包后的重传(RTT 聚在 RTO 附近)或队头阻塞。
|
||
/// </summary>
|
||
internal sealed class SlowShareOp : MetricOp
|
||
{
|
||
private readonly int _minSamples;
|
||
|
||
public SlowShareOp(int minSamples)
|
||
{
|
||
_minSamples = minSamples;
|
||
}
|
||
|
||
public override string Signature => $"slow_share/{_minSamples}/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var rtts = new List<(int Row, double Ms)>(c.Count);
|
||
for (int i = 0; i < c.Count; i++)
|
||
{
|
||
if (c.IsReplyLost(i) || !c.T1!.IsValid(i))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long rttUs = c.T4!.Values[i] - c.T1.Values[i];
|
||
if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i))
|
||
{
|
||
rttUs -= c.T3.Values[i] - c.T2.Values[i];
|
||
}
|
||
|
||
if (rttUs >= 0)
|
||
{
|
||
rtts.Add((i, rttUs / 1000.0));
|
||
}
|
||
}
|
||
|
||
if (rtts.Count < _minSamples)
|
||
{
|
||
return;
|
||
}
|
||
|
||
double[] sorted = [.. rtts.Select(r => r.Ms).Order()];
|
||
double baseline = sorted[sorted.Length / 2];
|
||
double threshold = Math.Max(baseline * 1.8, baseline + Math.Max(15, baseline * 0.35));
|
||
foreach ((int row, double ms) in rtts)
|
||
{
|
||
sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? 1 : 0, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
internal sealed class LossOp : MetricOp
|
||
{
|
||
private readonly Dir _dir;
|
||
|
||
public LossOp(Dir dir)
|
||
{
|
||
_dir = dir;
|
||
}
|
||
|
||
public override string Signature => "loss/" + _dir + "/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics || (_dir != Dir.Total && c.T2 is null))
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < c.Count; i++)
|
||
{
|
||
if (!c.T1!.IsValid(i))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
switch (_dir)
|
||
{
|
||
case Dir.Up:
|
||
sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? 1 : 0, 1);
|
||
break;
|
||
case Dir.Down:
|
||
// 只统计上行已经到达 Agent 的包:回复没回来就是下行丢失。
|
||
if (!c.IsUpLost(i))
|
||
{
|
||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||
}
|
||
|
||
break;
|
||
default:
|
||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>去除偏移和线性漂移后的单向时延变化:上行用 t2−t1,下行用 t4−t3。</summary>
|
||
internal sealed class OwdVarOp : MetricOp
|
||
{
|
||
private readonly Dir _dir;
|
||
|
||
public OwdVarOp(Dir dir)
|
||
{
|
||
_dir = dir;
|
||
}
|
||
|
||
public override string Signature => "owd_var/" + _dir + "/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
RawColumn? a = _dir == Dir.Up ? c.T1 : c.T3;
|
||
RawColumn? b = _dir == Dir.Up ? c.T2 : c.T4;
|
||
if (c.T1 is null || a is null || b is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var times = new List<double>(c.Count);
|
||
var delays = new List<double>(c.Count);
|
||
var utc = new List<long>(c.Count);
|
||
for (int i = 0; i < c.Count; i++)
|
||
{
|
||
if (!c.T1.IsValid(i) || !a.IsValid(i) || !b.IsValid(i))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
times.Add(c.T1.Values[i]);
|
||
delays.Add(b.Values[i] - a.Values[i]);
|
||
utc.Add(c.SentUtcMs(view, i));
|
||
}
|
||
|
||
JitterEmitter.Emit(times, delays, utc, sink);
|
||
}
|
||
}
|
||
|
||
internal static class JitterEmitter
|
||
{
|
||
public static void Emit(List<double> timesUs, List<double> delaysUs, List<long> utcMs, IObservationSink sink)
|
||
{
|
||
double[]? jitter = ClockDriftFit.Jitter([.. timesUs], [.. delaysUs]);
|
||
if (jitter is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < jitter.Length; i++)
|
||
{
|
||
sink.Emit(utcMs[i], jitter[i] / 1000.0);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
|
||
internal sealed class LossRunsOp : MetricOp
|
||
{
|
||
private readonly Dir _direction;
|
||
|
||
public LossRunsOp(Dir direction)
|
||
{
|
||
_direction = direction;
|
||
}
|
||
|
||
public override string Signature => $"loss_runs/{_direction}/2";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics)
|
||
{
|
||
return;
|
||
}
|
||
|
||
int run = 0;
|
||
long runStart = 0;
|
||
for (int i = 0; i < c.Count; i++)
|
||
{
|
||
if (!c.T1!.IsValid(i))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
bool lost = _direction switch
|
||
{
|
||
Dir.Up => c.IsUpLost(i),
|
||
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
|
||
_ => c.IsReplyLost(i),
|
||
};
|
||
if (lost)
|
||
{
|
||
if (run == 0)
|
||
{
|
||
runStart = c.SentUtcMs(view, i);
|
||
}
|
||
|
||
run++;
|
||
}
|
||
else if (run > 0)
|
||
{
|
||
sink.Emit(runStart, run);
|
||
run = 0;
|
||
}
|
||
}
|
||
|
||
if (run > 0)
|
||
{
|
||
sink.Emit(runStart, run);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>每个块一个最长连续丢包值;无丢包的有效块明确输出 0。</summary>
|
||
internal sealed class LossRunMaxOp(Dir direction) : MetricOp
|
||
{
|
||
public override string Signature => $"loss_run_max/{direction}/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics || (direction != Dir.Total && c.T2 is null))
|
||
{
|
||
return;
|
||
}
|
||
|
||
int run = 0;
|
||
int maximum = 0;
|
||
bool hasSample = false;
|
||
long sampleAt = 0;
|
||
for (int i = 0; i < c.Count; i++)
|
||
{
|
||
if (!c.T1!.IsValid(i))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
bool lost = direction switch
|
||
{
|
||
Dir.Up => c.IsUpLost(i),
|
||
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
|
||
_ => c.IsReplyLost(i),
|
||
};
|
||
if (direction == Dir.Down && c.IsUpLost(i))
|
||
{
|
||
// A request lost before reaching the Agent is not a downstream sample.
|
||
run = 0;
|
||
continue;
|
||
}
|
||
|
||
if (!hasSample)
|
||
{
|
||
sampleAt = c.SentUtcMs(view, i);
|
||
hasSample = true;
|
||
}
|
||
run = lost ? run + 1 : 0;
|
||
maximum = Math.Max(maximum, run);
|
||
}
|
||
|
||
if (hasSample)
|
||
{
|
||
sink.Emit(sampleAt, maximum);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
|
||
internal sealed class ReorderOp : MetricOp
|
||
{
|
||
public override string Signature => "reorder/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics || c.Seq is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var seen = new HashSet<long>();
|
||
long maxSeq = long.MinValue;
|
||
foreach (int row in c.ArrivalOrder())
|
||
{
|
||
long seq = c.Seq.Values[row];
|
||
if (!seen.Add(seq))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (seq < maxSeq)
|
||
{
|
||
sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1);
|
||
}
|
||
|
||
maxSeq = Math.Max(maxSeq, seq);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>重复:同一个序号收到不止一次。</summary>
|
||
internal sealed class DupOp : MetricOp
|
||
{
|
||
public override string Signature => "dup/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics || c.Seq is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var seen = new HashSet<long>();
|
||
foreach (int row in c.ArrivalOrder())
|
||
{
|
||
if (!seen.Add(c.Seq.Values[row]))
|
||
{
|
||
sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>卡顿:到达间隔超过“期望间隔 + 阈值”。</summary>
|
||
internal sealed class GapOp : MetricOp
|
||
{
|
||
private readonly GapOutput _output;
|
||
private readonly string _rateParam;
|
||
private readonly double _thresholdMs;
|
||
|
||
public GapOp(GapOutput output, string rateParam, double thresholdMs)
|
||
{
|
||
_output = output;
|
||
_rateParam = rateParam;
|
||
_thresholdMs = thresholdMs;
|
||
}
|
||
|
||
public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
var c = new EchoColumns(stream);
|
||
if (!c.HasBasics)
|
||
{
|
||
return;
|
||
}
|
||
|
||
double rate = Math.Max(0.1, view.ParamNumber(_rateParam, 1));
|
||
double expectedMs = 1000.0 / rate;
|
||
int[] order = c.ArrivalOrder();
|
||
for (int i = 1; i < order.Length; i++)
|
||
{
|
||
double gapMs = (c.T4!.Values[order[i]] - c.T4.Values[order[i - 1]]) / 1000.0;
|
||
if (gapMs - expectedMs <= _thresholdMs)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long at = view.ColumnToUtcMs(c.T4, c.T4.Values[order[i]]);
|
||
sink.Emit(at, _output == GapOutput.Count ? 1 : gapMs);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>每一行一个观测值,用于统计样本量。</summary>
|
||
internal sealed class RowCountOp : MetricOp
|
||
{
|
||
public override string Signature => "row_count/1";
|
||
|
||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||
{
|
||
RawColumn? time = null;
|
||
foreach (RawColumn column in stream.Columns)
|
||
{
|
||
if (column.Role != ColumnRole.None)
|
||
{
|
||
time = column;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (time is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < stream.Count; i++)
|
||
{
|
||
if (time.IsValid(i))
|
||
{
|
||
sink.Emit(view.ColumnToUtcMs(time, time.Values[i]), 1);
|
||
}
|
||
}
|
||
}
|
||
}
|