Files
nmon/internal/groups/groups_test.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)
}
}