Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
275 lines
10 KiB
Go
275 lines
10 KiB
Go
package groups
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/p95"
|
|
"github.com/v10networks/nmon/internal/p95/p95test"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
func lastPoint(pts [][2]float64) float64 { return pts[len(pts)-1][1] }
|
|
|
|
func TestOrientedSumSwapsInAndOutForAwayMembers(t *testing.T) {
|
|
r := newRig(t)
|
|
a, b := r.iface(t, false), r.iface(t, false)
|
|
from := time.Date(2026, 3, 1, 5, 0, 0, 0, time.UTC)
|
|
to := from.Add(time.Hour)
|
|
r.member(t, a, store.DirToward, from.Add(-time.Hour))
|
|
r.member(t, b, store.DirAway, from.Add(-time.Hour))
|
|
r.rates(a, from, to, constant(10, 2))
|
|
r.rates(b, from, to, constant(3, 7))
|
|
|
|
res, err := r.svc().GroupSeries(context.Background(), r.group.ID, from, to, 0)
|
|
must(t, err)
|
|
// in = a.in + b.out, out = a.out + b.in
|
|
if len(res.In) != 12 || lastPoint(res.In) != 17 || lastPoint(res.Out) != 5 {
|
|
t.Fatalf("in=%v out=%v", res.In, res.Out)
|
|
}
|
|
if len(res.Partial) != 0 {
|
|
t.Fatalf("complete data reported partial: %+v", res.Partial)
|
|
}
|
|
}
|
|
|
|
func TestMembershipAsOfEachTimestampLeavesJanuaryAloneWhenPortAddedInFebruary(t *testing.T) {
|
|
r := newRig(t)
|
|
a, b := r.iface(t, false), r.iface(t, false)
|
|
jan, feb := month(t, "2026-01"), month(t, "2026-02")
|
|
r.member(t, a, store.DirToward, jan.Start)
|
|
r.member(t, b, store.DirToward, feb.Start) // added on 1 February
|
|
for _, id := range []string{a, b} {
|
|
// Both ports carry traffic in January too: b's January data must not count.
|
|
r.rates(id, jan.Start.Add(-p95.Window), feb.End, constant(100, 10))
|
|
}
|
|
svc := r.svc()
|
|
|
|
res, err := svc.GroupSeries(context.Background(), r.group.ID, feb.Start.Add(-time.Hour), feb.Start.Add(time.Hour), 0)
|
|
must(t, err)
|
|
if res.In[0][1] != 100 || lastPoint(res.In) != 200 {
|
|
t.Fatalf("series across 1 Feb = %v", res.In)
|
|
}
|
|
pj, err := svc.GroupP95(context.Background(), r.group.ID, "2026-01")
|
|
must(t, err)
|
|
pf, err := svc.GroupP95(context.Background(), r.group.ID, "2026-02")
|
|
must(t, err)
|
|
if pj.InBps != 100 || pf.InBps != 200 || pj.MembersHash == pf.MembersHash {
|
|
t.Fatalf("january %+v, february %+v", pj.Result, pf.Result)
|
|
}
|
|
}
|
|
|
|
func TestLagMembersAreDroppedOnlyWhenTheirLagIsAlsoInTheGroup(t *testing.T) {
|
|
r := newRig(t)
|
|
lag, c1, c2 := r.iface(t, false), r.iface(t, false), r.iface(t, false)
|
|
from := time.Date(2026, 3, 1, 5, 0, 0, 0, time.UTC)
|
|
to := from.Add(time.Hour)
|
|
r.opts.LagParent = func(_ context.Context, ids []string) (map[string]string, error) {
|
|
return map[string]string{c1: lag, c2: lag}, nil
|
|
}
|
|
r.rates(lag, from, to, constant(30, 3))
|
|
r.rates(c1, from, to, constant(10, 1))
|
|
r.rates(c2, from, to, constant(20, 2))
|
|
series := func() float64 {
|
|
res, err := r.svc().GroupSeries(context.Background(), r.group.ID, from, to, 0)
|
|
must(t, err)
|
|
return lastPoint(res.In)
|
|
}
|
|
|
|
r.member(t, c1, store.DirToward, from.Add(-time.Hour))
|
|
r.member(t, c2, store.DirToward, from.Add(-time.Hour))
|
|
if got := series(); got != 30 {
|
|
t.Fatalf("members only: %v, want 10+20", got)
|
|
}
|
|
r.member(t, lag, store.DirToward, from.Add(-time.Hour))
|
|
if got := series(); got != 30 {
|
|
t.Fatalf("with the LAG as well: %v, want 30 (members dropped, not added again)", got)
|
|
}
|
|
}
|
|
|
|
func TestGroupMixingBothEndsOfALinkIsFlagged(t *testing.T) {
|
|
r := newRig(t)
|
|
a, b, c := r.iface(t, false), r.iface(t, false), r.iface(t, false)
|
|
now := r.clk.Now()
|
|
for _, id := range []string{a, b, c} {
|
|
r.member(t, id, store.DirToward, now.Add(-time.Hour))
|
|
}
|
|
flags, err := r.svc().Flags(context.Background(), r.group.ID)
|
|
must(t, err)
|
|
if len(flags) != 0 {
|
|
t.Fatalf("no link knowledge must flag nothing, got %+v", flags)
|
|
}
|
|
r.opts.LinkPeer = func(_ context.Context, ids []string) (map[string]string, error) {
|
|
return map[string]string{a: b}, nil
|
|
}
|
|
flags, err = r.svc().Flags(context.Background(), r.group.ID)
|
|
must(t, err)
|
|
if len(flags) != 1 || flags[0].Kind != FlagBothEnds || (flags[0].A+flags[0].B != a+b && flags[0].A+flags[0].B != b+a) {
|
|
t.Fatalf("flags = %+v", flags)
|
|
}
|
|
}
|
|
|
|
func TestUnreliableInterfacesNeverEnterASumOrAPercentile(t *testing.T) {
|
|
r := newRig(t)
|
|
good, bad := r.iface(t, false), r.iface(t, true)
|
|
m := month(t, "2026-04")
|
|
r.member(t, good, store.DirToward, m.Start)
|
|
r.member(t, bad, store.DirToward, m.Start)
|
|
r.rates(good, m.Start, m.End, constant(5, 5))
|
|
r.rates(bad, m.Start, m.End, constant(1000, 1000))
|
|
res, err := r.svc().GroupP95(context.Background(), r.group.ID, "2026-04")
|
|
must(t, err)
|
|
if res.InBps != 5 || res.OutBps != 5 {
|
|
t.Fatalf("unreliable port leaked into the p95: %+v", res.Result)
|
|
}
|
|
series, err := r.svc().GroupSeries(context.Background(), r.group.ID, m.Start, m.Start.Add(time.Hour), 0)
|
|
must(t, err)
|
|
if lastPoint(series.In) != 5 {
|
|
t.Fatalf("unreliable port leaked into the sum: %v", series.In)
|
|
}
|
|
}
|
|
|
|
func TestGroupP95IsTakenOnTheSummedSeriesNotAddedAcrossPorts(t *testing.T) {
|
|
r := newRig(t)
|
|
a, b := r.iface(t, false), r.iface(t, false)
|
|
m := month(t, "2026-04")
|
|
r.member(t, a, store.DirToward, m.Start)
|
|
r.member(t, b, store.DirToward, m.Start)
|
|
// a is busy (100) in the first half of the month and idle in the second; b the reverse.
|
|
half := m.Windows() / 2
|
|
r.rates(a, m.Start, m.End, func(i int) (float64, float64, bool) { return pick(i < half, 100, 0), 0, true })
|
|
r.rates(b, m.Start, m.End, func(i int) (float64, float64, bool) { return pick(i >= half, 100, 0), 0, true })
|
|
|
|
res, err := r.svc().GroupP95(context.Background(), r.group.ID, "2026-04")
|
|
must(t, err)
|
|
pa, _ := p95.NearestRank(constantHalf(m.Windows(), half, true))
|
|
pb, _ := p95.NearestRank(constantHalf(m.Windows(), half, false))
|
|
if res.InBps != 100 || pa+pb != 200 {
|
|
t.Fatalf("group p95 = %v (per-port %v + %v)", res.InBps, pa, pb)
|
|
}
|
|
if res.InBps == pa+pb {
|
|
t.Fatal("group p95 equals the sum of per-port percentiles")
|
|
}
|
|
}
|
|
|
|
func pick(c bool, a, b float64) float64 {
|
|
if c {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func constantHalf(n, half int, first bool) []float64 {
|
|
out := make([]float64, n)
|
|
for i := range out {
|
|
if (i < half) == first {
|
|
out[i] = 100
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestMissingMemberSamplesMakeSpansPartialAndLowerCoverage(t *testing.T) {
|
|
r := newRig(t)
|
|
a, b := r.iface(t, false), r.iface(t, false)
|
|
m := month(t, "2026-04")
|
|
r.member(t, a, store.DirToward, m.Start)
|
|
r.member(t, b, store.DirToward, m.Start)
|
|
r.rates(a, m.Start, m.End, constant(10, 10))
|
|
// b is silent for 20% of the month, in the middle.
|
|
n := m.Windows()
|
|
r.rates(b, m.Start, m.End, func(i int) (float64, float64, bool) { return 5, 5, i < n*4/10 || i >= n*6/10 })
|
|
|
|
res, err := r.svc().GroupSeries(context.Background(), r.group.ID, m.Start, m.End, time.Hour)
|
|
must(t, err)
|
|
if len(res.Partial) != 1 || !res.Partial[0].From.Equal(m.Start.Add(time.Duration(n*4/10)*p95.Window)) {
|
|
t.Fatalf("partial spans = %+v", res.Partial)
|
|
}
|
|
p, err := r.svc().GroupP95(context.Background(), r.group.ID, "2026-04")
|
|
must(t, err)
|
|
if !p.Partial() || p.Coverage < 0.79 || p.Coverage > 0.81 || p.InBps != 15 {
|
|
t.Fatalf("group p95 = %+v (coverage should be ~0.8, p95 of complete windows 15)", p.Result)
|
|
}
|
|
}
|
|
|
|
func TestCoarserStepReportsTheMeanOfTheFiveMinuteSums(t *testing.T) {
|
|
r := newRig(t)
|
|
a := r.iface(t, false)
|
|
from := time.Date(2026, 3, 1, 5, 0, 0, 0, time.UTC)
|
|
r.member(t, a, store.DirToward, from.Add(-time.Hour))
|
|
r.rates(a, from, from.Add(time.Hour), func(i int) (float64, float64, bool) { return float64(i * 10), 0, true })
|
|
res, err := r.svc().GroupSeries(context.Background(), r.group.ID, from, from.Add(30*time.Minute), 15*time.Minute)
|
|
must(t, err)
|
|
if len(res.In) != 2 || res.In[0][1] != 10 || res.In[1][1] != 40 {
|
|
t.Fatalf("15 minute buckets = %v", res.In)
|
|
}
|
|
}
|
|
|
|
func TestMaterialisedGroupFigureRecordsMembershipAndIsIgnoredWhenMembershipChanged(t *testing.T) {
|
|
m := month(t, "2026-04")
|
|
r := newRigAt(t, m.End.Add(30*time.Minute))
|
|
a, b := r.iface(t, false), r.iface(t, false)
|
|
r.member(t, a, store.DirToward, m.Start)
|
|
r.rates(a, m.Start, m.End, constant(10, 1))
|
|
r.rates(b, m.Start, m.End, constant(100, 1))
|
|
svc := r.svc()
|
|
job, err := NewJob(JobOptions{Service: svc, Query: r.long, Writer: r.long, Alarms: r.alarms, Clock: r.clk, Origin: m.Start})
|
|
must(t, err)
|
|
must(t, job.RunOnce(context.Background()))
|
|
|
|
recs := r.long.Records(p95.MetricGroupP95)
|
|
if len(recs) != 2 || p95test.Label(recs[0], "month") != "2026-04" || p95test.Label(recs[0], "members_hash") == "" {
|
|
t.Fatalf("records = %+v", recs)
|
|
}
|
|
got, err := svc.GroupP95(context.Background(), r.group.ID, "2026-04")
|
|
must(t, err)
|
|
if got.Source != p95.SourceMaterialised || got.InBps != 10 || got.MembersHash != p95test.Label(recs[0], "members_hash") {
|
|
t.Fatalf("after job: %+v", got.Result)
|
|
}
|
|
// A back-dated member changes the membership of April: the record no longer applies.
|
|
r.member(t, b, store.DirToward, m.Start)
|
|
got, err = svc.GroupP95(context.Background(), r.group.ID, "2026-04")
|
|
must(t, err)
|
|
if got.Source != p95.SourceLive || got.InBps != 110 {
|
|
t.Fatalf("after membership change: %+v", got.Result)
|
|
}
|
|
writes := r.long.Writes()
|
|
must(t, job.RunOnce(context.Background()))
|
|
if r.long.Writes() != writes {
|
|
t.Fatal("a finished month must not be written twice")
|
|
}
|
|
}
|
|
|
|
func TestGroupMaterialisationFailureAlarmsAfterADayAndRecoversExactly(t *testing.T) {
|
|
m := month(t, "2026-04")
|
|
r := newRigAt(t, m.End.Add(time.Hour))
|
|
a := r.iface(t, false)
|
|
r.member(t, a, store.DirToward, m.Start)
|
|
r.rates(a, m.Start, m.End, constant(7, 7))
|
|
job, err := NewJob(JobOptions{Service: r.svc(), Query: r.long, Writer: r.long, Alarms: r.alarms, Clock: r.clk, Origin: m.Start})
|
|
must(t, err)
|
|
r.long.Fail(true)
|
|
if err := job.RunOnce(context.Background()); err == nil {
|
|
t.Fatal("expected the failed write")
|
|
}
|
|
if n, _ := r.alarms.Counts(); n != 0 {
|
|
t.Fatal("alarm before the month was a day late")
|
|
}
|
|
r.clk.Advance(26 * time.Hour)
|
|
_ = job.RunOnce(context.Background())
|
|
if n, _ := r.alarms.Counts(); n != 1 {
|
|
t.Fatalf("alarms = %d", n)
|
|
}
|
|
r.clk.Advance(35 * 24 * time.Hour)
|
|
r.long.Fail(false)
|
|
must(t, job.RunOnce(context.Background()))
|
|
got, err := r.svc().GroupP95(context.Background(), r.group.ID, "2026-04")
|
|
must(t, err)
|
|
if got.Source != p95.SourceMaterialised || got.InBps != 7 {
|
|
t.Fatalf("recovered figure = %+v", got.Result)
|
|
}
|
|
if _, cleared := r.alarms.Counts(); cleared != 1 {
|
|
t.Fatalf("cleared %d", cleared)
|
|
}
|
|
}
|