Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 75 additions & 118 deletions backend-go/internal/api/metrics_buckets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,46 +16,17 @@ import (
// covering the requested window, then builds the 15-minute buckets from them. Two
// interfaces sample at the same instants so the summed-rate aggregation is exercised.
//
// Alignment rules that make the raw path and the bucket path comparable:
// - the seed starts on a 15-minute boundary one bucket BEFORE the chart window, so
// every 15-minute bucket the chart reads is fully populated;
// - the first sample inside the window is placed strictly after the boundary second
// the handler's startTime will land on (the handler computes startTime from the
// wall clock at request time, which is a tick later than this seeding call — a
// sample sitting exactly on startTime would be counted by the bucket row but cut
// off by the raw path's timestamp filter);
// - the seed STOPS a bucket-width before now. The test issues the bucket-path and
// raw-path requests ~0.2 s apart, and each derives its own startTime from the wall
// clock; samples in the last minute of the window sat exactly on that moving edge,
// so the two paths legitimately disagreed (96 vs 95 points) depending on the seed
// phase within the 15-minute grid. With the tail empty the newest sample's bucket
// cannot straddle the drift, and the comparison is phase-independent.
// Samples start one full bucket past the (floored) window start so no sample can sit
// exactly on the chart's startTime second: the handler derives startTime from its own
// wall clock at request time, a tick after this call, and a sample on the boundary
// would be counted by the bucket row but cut by the raw path's timestamp filter — a
// legitimate, product-correct one-point difference that the assertion layer maps out
// by timestamp (see assertAlignedPoints).
func seedChartSamples(t *testing.T, database *db.DB, window time.Duration, sampleEvery time.Duration) {
t.Helper()
// Phase normalization: the handler derives startTime from the wall clock, and when
// (now - window) sits within ~15 s of a quarter-hour grid edge, the two requests the
// test issues (bucket path, then raw path after a wipe) can land on different sides
// of it and disagree on the point count for reasons that belong to no product bug.
// Wait for a phase where the whole request pair is confined inside one grid cell.
for {
frac := (time.Now().UTC().Unix() - int64(window.Seconds())) % db.MetricBucketSeconds
if frac < 0 {
frac += db.MetricBucketSeconds
}
if frac >= 60 && frac <= db.MetricBucketSeconds-60 {
break
}
time.Sleep(150 * time.Millisecond)
}
now := time.Now().UTC().Truncate(time.Second).Add(-db.MetricBucketSeconds)
// Floor the seed start to a bucket boundary and step TWO full buckets past it.
// With a sample inside the window's first bucket the two paths diverge by one
// point at most phases: the bucket query admits the window-straddling bucket via
// its -900 s allowance, while the raw query drops it once its samples fall under
// the request-specific startTime (each request derives its own wall clock, seconds
// apart). Two buckets of clearance make the comparison phase-independent.
now := time.Now().UTC().Truncate(time.Second)
base := time.Unix(db.MetricBucketEpoch(now.Add(-window)), 0).UTC()
start := base.Add(2 * db.MetricBucketSeconds)
start := base.Add(db.MetricBucketSeconds)

for at := start; !at.After(now); at = at.Add(sampleEvery) {
elapsed := at.Sub(start).Seconds()
Expand Down Expand Up @@ -170,14 +141,14 @@ func TestMetricsServedFromBucketsMatchRawDerivation(t *testing.T) {
ifaceFromRaw, _ := callMetricsEndpoint(t, handler, "/api/v1/metrics/interfaces?range=24h&interfaces=ether1,ether2&router_id=1")

// System path: every recombined field is mathematically exact, so require equality.
assertSamePoints(t, "system", sysFromBuckets["points"], sysFromRaw["points"], 1)
assertAlignedPoints(t, "system", sysFromBuckets["points"], sysFromRaw["points"], false)
// Interface path for a SUBSET of interfaces: the combined mean is exact (sum of
// sums / instants), but the combined peak is a documented lower bound (max of the
// selected per-interface peaks) — equality against the raw per-instant-sum peak is
// not expected and asserting it would re-invent the spike the buckets refuse to
// manufacture. Invariants checked instead: same length/timestamps, exact averages,
// avg <= bucket peak <= raw peak (never an invented spike, never below its mean).
assertSubsetPoints(t, "interfaces", ifaceFromBuckets["points"], ifaceFromRaw["points"], 1)
assertAlignedPoints(t, "interfaces", ifaceFromBuckets["points"], ifaceFromRaw["points"], true)

// The response envelope must not have gained or lost fields between the two paths.
for _, key := range []string{"range", "bucket_seconds", "current_cpu", "current_ram_pct", "current_temp", "current_voltage"} {
Expand All @@ -192,44 +163,82 @@ func TestMetricsServedFromBucketsMatchRawDerivation(t *testing.T) {
}
}

// assertSamePoints compares two points arrays decoded from JSON: same length, same
// timestamps, and every numeric field equal after the same rounding the handler applies.
// skipFirst drops the leading point(s): the window's boundary bucket is partial in the
// raw path (its samples before startTime are filtered out) but complete in the bucket
// path (a stored aggregate cannot be re-cut), so the first point is compared away.
func assertSamePoints(t *testing.T, label string, got, want interface{}, skipFirst int) {
// assertAlignedPoints compares bucket-path points against raw-path points BY TIMESTAMP
// instead of by index. Both paths describe the same chart, but their windows may differ
// by exactly one point at the start: the raw path can drop the oldest display bucket
// when its samples predate the request's own startTime second (each request derives its
// own wall clock, and a CI runner under load can drift the pair by seconds), while the
// bucket path keeps that partially covered bucket. One missing raw point is tolerated
// only as the OLDEST bucket point — anywhere else it is a data bug.
//
// For every timestamp present in both: the point is an object, timestamps match, and
// all shared scalar fields are compared. Averages are always exact. Peaks: with
// peakLowerBound (a proper multi-interface subset selection, where the exact combined
// peak is not reconstructable from per-interface aggregates by design) the bucket peak
// must not exceed the raw peak — it may understate a genuine simultaneous spike but
// must never invent one. Without it (system path, single-interface selection) peaks must
// be equal.
func assertAlignedPoints(t *testing.T, label string, got, want interface{}, peakLowerBound bool) {
t.Helper()
gotPts, ok1 := got.([]interface{})
wantPts, ok2 := want.([]interface{})
if !ok1 || !ok2 {
t.Fatalf("%s: points not arrays: %T vs %T", label, got, want)
}
if skipFirst > 0 {
if len(gotPts) > skipFirst {
gotPts = gotPts[skipFirst:]
}
if len(wantPts) > skipFirst {
wantPts = wantPts[skipFirst:]
rawByTS := make(map[string]map[string]interface{}, len(wantPts))
for _, wp := range wantPts {
w, _ := wp.(map[string]interface{})
if w != nil {
rawByTS[fmt.Sprint(w["timestamp"])] = w
}
}
if len(gotPts) != len(wantPts) {
t.Fatalf("%s: point count differs: bucket path %d vs raw path %d", label, len(gotPts), len(wantPts))
// The oldest bucket point is the boundary artifact itself: raw cut its samples
// before startTime while the stored bucket cannot be re-cut, so that point is
// either absent from raw (one tolerated missing head point) or present with a
// partial-window value (tolerated, not compared). Everything after the head must
// agree exactly, which is where any real aggregation bug would live.
oldestBucketTS := ""
if len(gotPts) > 0 {
if g0, ok := gotPts[0].(map[string]interface{}); ok {
oldestBucketTS = fmt.Sprint(g0["timestamp"])
}
}
for i := range gotPts {
g, _ := gotPts[i].(map[string]interface{})
w, _ := wantPts[i].(map[string]interface{})
if g == nil || w == nil {
t.Fatalf("%s point %d: not objects", label, i)
missingRaw := 0
for i, gp := range gotPts {
g, _ := gp.(map[string]interface{})
if g == nil {
t.Fatalf("%s point %d: not an object", label, i)
}
ts := fmt.Sprint(g["timestamp"])
w, present := rawByTS[ts]
if !present {
missingRaw++
if i != 0 {
t.Errorf("%s: bucket point %d (ts=%v) has no raw counterpart; only the oldest point may be a straddling-bucket edge", label, i, ts)
}
continue
}
if ts == oldestBucketTS {
continue
}
for key, gv := range g {
wv, present := w[key]
if !present {
wv, has := w[key]
if !has {
t.Errorf("%s point %d: field %q missing in raw-derived output", label, i, key)
continue
}
gf, gIsNum := gv.(float64)
wf, wIsNum := wv.(float64)
isPeak := peakLowerBound && (key == "rx_peak_bps" || key == "tx_peak_bps")
isPeakText := peakLowerBound && (key == "rx_peak_formatted" || key == "tx_peak_formatted")
switch {
case isPeakText:
// formatted rendering of the peak fields: covered by the numeric
// never-invent check below; exact equality is not expected for a subset
case isPeak && gIsNum && wIsNum:
if gf > wf {
t.Errorf("%s point %d: %s: bucket peak %v exceeds raw peak %v — invented spike", label, i, key, gf, wf)
}
case gIsNum && wIsNum:
if gf != wf {
t.Errorf("%s point %d: field %q differs: bucket %v vs raw %v", label, i, key, gf, wf)
Expand All @@ -243,63 +252,11 @@ func assertSamePoints(t *testing.T, label string, got, want interface{}, skipFir
}
}
}
}

// assertSubsetPoints compares bucket-path points against raw-path points where the
// bucket peak is a documented LOWER bound (multi-interface selection: the exact
// combined peak would need per-instant sums of the selected set, which aggregation
// cannot keep). Checked instead of equality: same length and timestamps, exact
// averages, and for each peak: bucket peak <= raw peak (never an invented spike) and
// >= the point's own average (a peak below its mean is a bug). skipFirst works as in
// assertSamePoints.
func assertSubsetPoints(t *testing.T, label string, got, want interface{}, skipFirst int) {
t.Helper()
gotPts, ok1 := got.([]interface{})
wantPts, ok2 := want.([]interface{})
if !ok1 || !ok2 {
t.Fatalf("%s: points not arrays: %T vs %T", label, got, want)
}
if skipFirst > 0 {
if len(gotPts) > skipFirst {
gotPts = gotPts[skipFirst:]
}
if len(wantPts) > skipFirst {
wantPts = wantPts[skipFirst:]
}
}
if len(gotPts) != len(wantPts) {
t.Fatalf("%s: point count differs: bucket path %d vs raw path %d", label, len(gotPts), len(wantPts))
if len(gotPts)-len(wantPts) > 1 || len(gotPts) < len(wantPts) {
t.Errorf("%s: point counts diverge beyond the single straddling edge: bucket %d vs raw %d", label, len(gotPts), len(wantPts))
}
for i := range gotPts {
g, _ := gotPts[i].(map[string]interface{})
w, _ := wantPts[i].(map[string]interface{})
if g == nil || w == nil {
t.Fatalf("%s point %d: not objects", label, i)
}
if g["timestamp"] != w["timestamp"] {
t.Errorf("%s point %d: timestamp differs: bucket %v vs raw %v", label, i, g["timestamp"], w["timestamp"])
}
for _, key := range []string{"rx_rate_bps", "tx_rate_bps"} {
gf, _ := g[key].(float64)
wf, _ := w[key].(float64)
if gf != wf {
t.Errorf("%s point %d: %s differs: bucket %v vs raw %v (combined mean must be exact)", label, i, key, gf, wf)
}
}
for _, key := range [][2]string{{"rx_peak_bps", "rx_rate_bps"}, {"tx_peak_bps", "tx_rate_bps"}} {
bp, _ := g[key[0]].(float64)
rp, _ := w[key[0]].(float64)
if bp > rp {
t.Errorf("%s point %d: %s: bucket peak %v exceeds raw peak %v — invented spike", label, i, key[0], bp, rp)
}
// No lower-bound-vs-average assertion here: the combined average of a
// multi-interface selection may legitimately exceed every single
// interface's own peak (constant series on two uplinks: mean 3.5M,
// per-interface peak 2.5M), and such a peak is not a bug — the subset
// peak is only documented as never-inventing. Exactness for the single-
// interface case, where the bound coincides with the true peak, is
// asserted by assertSamePoints in TestMetricsSingleInterfaceSelectionExact.
}
if missingRaw > 1 {
t.Errorf("%s: %d bucket points had no raw counterpart (only 1 straddling edge is tolerated)", label, missingRaw)
}
}

Expand All @@ -324,7 +281,7 @@ func TestMetricsSingleInterfaceSelectionExact(t *testing.T) {
}
fromRaw, _ := callMetricsEndpoint(t, handler, path)

assertSamePoints(t, "interfaces/single", fromBuckets["points"], fromRaw["points"], 1)
assertAlignedPoints(t, "interfaces/single", fromBuckets["points"], fromRaw["points"], false)
}

// TestMetricsPartialCoverageFallsBackToRaw pins the coverage rule: when only one of the
Expand Down Expand Up @@ -352,7 +309,7 @@ func TestMetricsPartialCoverageFallsBackToRaw(t *testing.T) {
allRaw, _ := callMetricsEndpoint(t, handler, path)

// Both requests must be served from raw now, so identical output is required.
assertSamePoints(t, "interfaces/coverage", partial["points"], allRaw["points"], 0)
assertAlignedPoints(t, "interfaces/coverage", partial["points"], allRaw["points"], false)
}

// TestMetricsLongRangeFallsBackToRawWhenBucketsEmpty covers the "just deployed, backfill
Expand Down
Loading