New internal/web/service/probe package keeps probe history in its own probe.db beside the panel DB (single connection, WAL), so history writes never contend with the panel write lock. - Minute rows per hub semantics: a held-reading rhythm books traffic against each reading own arrival; minute buckets merge (means, rounded int means, peaks) and the stored net rate derives from the kernel counters between row marks, falling back to agent rates at epoch breaks. - probe_traffic ledger: accumulate is a line port of hub traffic.rs (baseline alignment, same-boot shrink books 0, epoch change books 0, unseen-boot exception books the whole counters-since-boot measured against the master last-seen stamp, per-direction period resets, saturating adds); reads gate stale day/month stamps to zero. - probe_meta rows are ensured for every reporting agent (B3 edits them in place); probe_ping_* tables are created now, written by B4. - Hourly roll_up behind a watermark (hour-aligned, late grace, pre-retention hours skipped unfoldered) and watermark-guarded chunked prune (minute tier 7 days or the retention window when shorter; hourly tier to retention_days, default 7, cap 365). - Wiring: heartbeat files probe samples after the snapshot update (last-seen captured before touch); housekeeping hangs off the existing robfig/cron in web.startTask (@hourly + one pass a minute after startup, mirroring the hub boot pass); Server.stop books held readings. Every failure path logs and degrades - a broken probe.db never fails a heartbeat or the panel.
271 lines
9.5 KiB
Go
271 lines
9.5 KiB
Go
package probe
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
st, err := Open(filepath.Join(t.TempDir(), "probe.db"))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
return st
|
|
}
|
|
|
|
func book(t *testing.T, st *Store, agent int64, boot string, rx, tx int64, at time.Time, booted *int64, lastSeen int64) Traffic {
|
|
t.Helper()
|
|
tr, err := st.accumulate(agent, boot, rx, tx, at, booted, lastSeen)
|
|
if err != nil {
|
|
t.Fatalf("accumulate: %v", err)
|
|
}
|
|
return tr
|
|
}
|
|
|
|
func i64p(v int64) *int64 { return &v }
|
|
|
|
func TestPeriodStart(t *testing.T) {
|
|
d := func(y int, m time.Month, day int) time.Time {
|
|
return time.Date(y, m, day, 0, 0, 0, 0, time.Local)
|
|
}
|
|
cases := []struct {
|
|
today time.Time
|
|
reset int64
|
|
want time.Time
|
|
}{
|
|
{d(2026, 10, 23), 1, d(2026, 10, 1)},
|
|
{d(2026, 10, 23), 23, d(2026, 10, 23)},
|
|
{d(2026, 10, 23), 24, d(2026, 9, 24)},
|
|
{d(2026, 2, 15), 31, d(2026, 1, 31)}, // Jan has 31
|
|
{d(2026, 2, 28), 31, d(2026, 2, 28)}, // short month: reset lands on last day
|
|
{d(2026, 3, 1), 31, d(2026, 2, 28)}, // Mar 1 with reset 31 is still in Feb's period
|
|
{d(2026, 3, 31), 31, d(2026, 3, 31)},
|
|
{d(2026, 1, 1), 15, d(2025, 12, 15)}, // wraparound into previous year
|
|
{d(2026, 10, 23), 0, d(2026, 10, 1)}, // clamp low
|
|
{d(2026, 10, 23), 99, d(2026, 9, 30)}, // clamp high: Sep has 30
|
|
}
|
|
for _, c := range cases {
|
|
if got := PeriodStart(c.today, c.reset); !got.Equal(c.want) {
|
|
t.Errorf("PeriodStart(%v, %d) = %v, want %v", c.today, c.reset, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAccumulateFirstReadingAlignsBaseline(t *testing.T) {
|
|
st := newTestStore(t)
|
|
now := time.Now()
|
|
tr := book(t, st, 1, "k1/fp", 1_000_000, 2_000_000, now, nil, 0)
|
|
if tr.TotalRx != 0 || tr.TotalTx != 0 || tr.MonthRx != 0 || tr.DayRx != 0 {
|
|
t.Fatalf("first reading must book 0, got %+v", tr)
|
|
}
|
|
// Same boot, counters climb: the delta books.
|
|
tr = book(t, st, 1, "k1/fp", 1_000_600, 2_000_900, now.Add(time.Minute), nil, now.Unix())
|
|
if tr.TotalRx != 600 || tr.TotalTx != 900 || tr.MonthRx != 600 || tr.DayTx != 900 {
|
|
t.Fatalf("delta not booked: %+v", tr)
|
|
}
|
|
}
|
|
|
|
func TestAccumulateCounterShrinkSameBoot(t *testing.T) {
|
|
st := newTestStore(t)
|
|
now := time.Now()
|
|
book(t, st, 1, "k1/fp", 5000, 5000, now, nil, 0)
|
|
book(t, st, 1, "k1/fp", 6000, 6000, now.Add(time.Minute), nil, now.Unix())
|
|
// A counted interface vanishes: the sum shrinks. Booking it again
|
|
// would double-count the remainder, so the delta is 0 and the
|
|
// baseline re-aligns.
|
|
tr := book(t, st, 1, "k1/fp", 2000, 2000, now.Add(2*time.Minute), nil, now.Unix())
|
|
if tr.TotalRx != 1000 {
|
|
t.Fatalf("shrink must book 0, total=%d", tr.TotalRx)
|
|
}
|
|
tr = book(t, st, 1, "k1/fp", 2500, 2600, now.Add(3*time.Minute), nil, now.Unix())
|
|
if tr.TotalRx != 1500 || tr.TotalTx != 1600 {
|
|
t.Fatalf("post-shrink delta wrong: %+v", tr)
|
|
}
|
|
}
|
|
|
|
func TestAccumulateBootChangeZeroDelta(t *testing.T) {
|
|
st := newTestStore(t)
|
|
now := time.Now()
|
|
book(t, st, 1, "k1/fpA", 1000, 1000, now, nil, 0)
|
|
book(t, st, 1, "k1/fpA", 2000, 2000, now.Add(time.Minute), nil, now.Unix())
|
|
// Same kernel, different interface digest (a counted iface joined):
|
|
// epoch changed, delta must be 0 even though booted looks recent.
|
|
booted := now.Add(30 * time.Second).Unix()
|
|
tr := book(t, st, 1, "k1/fpB", 9000, 9000, now.Add(2*time.Minute), &booted, now.Add(time.Minute).Unix())
|
|
if tr.TotalRx != 1000 {
|
|
t.Fatalf("epoch change must book 0, total=%d", tr.TotalRx)
|
|
}
|
|
}
|
|
|
|
func TestAccumulateUnseenBootBooksWholeReading(t *testing.T) {
|
|
st := newTestStore(t)
|
|
t0 := time.Now().Add(-2 * time.Hour)
|
|
book(t, st, 1, "k1/fp", 500, 500, t0, nil, 0)
|
|
tr := book(t, st, 1, "k1/fp", 800, 900, t0.Add(time.Minute), nil, t0.Unix())
|
|
if tr.TotalRx != 300 {
|
|
t.Fatalf("setup delta wrong: %+v", tr)
|
|
}
|
|
// The machine reboots while the master is not hearing from it:
|
|
// new kernel boot, booted after the master's last-seen stamp, so
|
|
// the whole counters-since-boot book.
|
|
booted := t0.Add(30 * time.Minute).Unix()
|
|
tr = book(t, st, 1, "k2/fp", 200, 100, t0.Add(31*time.Minute), &booted, t0.Add(time.Minute).Unix())
|
|
if tr.TotalRx != 500 || tr.TotalTx != 500 {
|
|
t.Fatalf("unseen boot must book whole reading: %+v", tr)
|
|
}
|
|
// And it continues from the new baseline.
|
|
tr = book(t, st, 1, "k2/fp", 350, 100, t0.Add(32*time.Minute), &booted, t0.Add(31*time.Minute).Unix())
|
|
if tr.TotalRx != 650 {
|
|
t.Fatalf("post-reboot delta wrong: %+v", tr)
|
|
}
|
|
}
|
|
|
|
func TestAccumulateBootChangeSeenRecentlyBooksZero(t *testing.T) {
|
|
st := newTestStore(t)
|
|
t0 := time.Now().Add(-time.Hour)
|
|
book(t, st, 1, "k1/fp", 1000, 1000, t0, nil, 0)
|
|
book(t, st, 1, "k1/fp", 2000, 2000, t0.Add(time.Minute), nil, t0.Unix())
|
|
// A different kernel boot that started BEFORE the master last saw
|
|
// the node (e.g. a second machine sharing the token): not the
|
|
// unseen-boot exception; books 0.
|
|
booted := t0.Add(30 * time.Second).Unix() // < lastSeen t0+60s
|
|
tr := book(t, st, 1, "k2/fp", 5000, 5000, t0.Add(2*time.Minute), &booted, t0.Add(time.Minute).Unix())
|
|
if tr.TotalRx != 1000 {
|
|
t.Fatalf("seen boot change must book 0, total=%d", tr.TotalRx)
|
|
}
|
|
}
|
|
|
|
func TestAccumulateCrossDay(t *testing.T) {
|
|
st := newTestStore(t)
|
|
day1 := dateOnly(time.Now()).Add(-48 * time.Hour) // midnight, two days ago
|
|
r1 := day1.Add(23*time.Hour + 58*time.Minute) // day D 23:58
|
|
r2 := day1.Add(23*time.Hour + 59*time.Minute) // day D 23:59
|
|
r3 := day1.Add(24*time.Hour + time.Minute) // day D+1 00:01
|
|
book(t, st, 1, "k/fp", 1000, 1000, r1, nil, 0)
|
|
tr := book(t, st, 1, "k/fp", 1100, 1100, r2, nil, r1.Unix())
|
|
if tr.DayRx != 100 {
|
|
t.Fatalf("day1 day_rx=%d, want 100", tr.DayRx)
|
|
}
|
|
tr = book(t, st, 1, "k/fp", 1150, 1150, r3, nil, r2.Unix())
|
|
if tr.DayRx != 50 || tr.TotalRx != 150 || tr.MonthRx != 150 {
|
|
t.Fatalf("cross-day split wrong: %+v", tr)
|
|
}
|
|
if tr.DayStart != dateOnly(r3).Format(dateLayout) {
|
|
t.Fatalf("day stamp=%s", tr.DayStart)
|
|
}
|
|
}
|
|
|
|
func TestAccumulateCrossPeriodWithResetDay(t *testing.T) {
|
|
st := newTestStore(t)
|
|
// reset day 15 on the meta row.
|
|
if err := st.ensureRows(7); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.db.Exec(`UPDATE probe_meta SET traffic_reset_day = 15 WHERE agent_id = 7`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now()
|
|
// Find a date in the current period and one in the next, both <= now
|
|
// when possible: use period arithmetic from PeriodStart itself.
|
|
periodStart := PeriodStart(dateOnly(now), 15)
|
|
inPeriod := periodStart.Add(24 * time.Hour)
|
|
if inPeriod.After(now) {
|
|
inPeriod = periodStart
|
|
}
|
|
book(t, st, 7, "k/fp", 1000, 1000, inPeriod, nil, 0)
|
|
tr := book(t, st, 7, "k/fp", 1300, 1300, inPeriod.Add(time.Minute), nil, inPeriod.Unix())
|
|
if tr.MonthRx != 300 {
|
|
t.Fatalf("month before boundary=%d, want 300", tr.MonthRx)
|
|
}
|
|
nextPeriod := PeriodStart(periodStart.AddDate(0, 2, 0), 15)
|
|
at := nextPeriod.Add(time.Minute)
|
|
tr = book(t, st, 7, "k/fp", 1500, 1500, at, nil, inPeriod.Unix())
|
|
// Delta 200 lands in the new period; the month counter restarts at
|
|
// the delta alone, the total keeps everything. (When the booking is
|
|
// future-dated relative to a held-back stamp, accumulate's dated()
|
|
// guard applies; both stamps here precede the booking date.)
|
|
if tr.TotalRx != 500 {
|
|
t.Fatalf("total=%d, want 500", tr.TotalRx)
|
|
}
|
|
if tr.MonthRx != 200 {
|
|
t.Fatalf("month after boundary=%d, want 200", tr.MonthRx)
|
|
}
|
|
if tr.MonthStart != nextPeriod.Format(dateLayout) {
|
|
t.Fatalf("month stamp=%s, want %s", tr.MonthStart, nextPeriod.Format(dateLayout))
|
|
}
|
|
}
|
|
|
|
func TestMonthUsedModes(t *testing.T) {
|
|
tr := Traffic{MonthRx: 100, MonthTx: 250}
|
|
if got := tr.MonthUsed("sum"); got != 350 {
|
|
t.Errorf("sum=%d", got)
|
|
}
|
|
if got := tr.MonthUsed("up"); got != 250 {
|
|
t.Errorf("up=%d", got)
|
|
}
|
|
if got := tr.MonthUsed("down"); got != 100 {
|
|
t.Errorf("down=%d", got)
|
|
}
|
|
if got := tr.MonthUsed("max"); got != 250 {
|
|
t.Errorf("max=%d", got)
|
|
}
|
|
if got := tr.MonthUsed(""); got != 350 {
|
|
t.Errorf("default=%d", got)
|
|
}
|
|
}
|
|
|
|
func TestTrafficReadGating(t *testing.T) {
|
|
st := newTestStore(t)
|
|
// Book in an old period (two months ago): month figures belong to
|
|
// that period and must read back as zero, totals intact.
|
|
old := dateOnly(time.Now()).AddDate(0, -2, 0)
|
|
book(t, st, 3, "k/fp", 1000, 1000, old, nil, 0)
|
|
book(t, st, 3, "k/fp", 1400, 1500, old.Add(time.Minute), nil, old.Unix())
|
|
got, ok, err := st.TrafficFor(3)
|
|
if err != nil || !ok {
|
|
t.Fatalf("TrafficFor: ok=%v err=%v", ok, err)
|
|
}
|
|
if got.TotalRx != 400 || got.TotalTx != 500 {
|
|
t.Fatalf("totals must survive gating: %+v", got)
|
|
}
|
|
if got.MonthRx != 0 || got.MonthTx != 0 || got.DayRx != 0 {
|
|
t.Fatalf("stale period must gate to zero: %+v", got)
|
|
}
|
|
all, err := st.AllTraffic()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if all[3].TotalRx != 400 || all[3].MonthRx != 0 {
|
|
t.Fatalf("AllTraffic gating wrong: %+v", all[3])
|
|
}
|
|
if _, ok, _ := st.TrafficFor(999); ok {
|
|
t.Fatal("unknown agent must report not-found")
|
|
}
|
|
}
|
|
|
|
func TestSetTrafficStampsCurrentPeriod(t *testing.T) {
|
|
st := newTestStore(t)
|
|
now := time.Now()
|
|
book(t, st, 5, "k/fp", 1000, 1000, now, nil, 0)
|
|
ok, err := st.SetTraffic(5, i64p(10_000), nil, i64p(700), nil)
|
|
if err != nil || !ok {
|
|
t.Fatalf("SetTraffic: ok=%v err=%v", ok, err)
|
|
}
|
|
got, _, _ := st.TrafficFor(5)
|
|
if got.TotalRx != 10_000 || got.MonthRx != 700 {
|
|
t.Fatalf("correction not visible: %+v", got)
|
|
}
|
|
// The next booking must not discard the correction: month is
|
|
// stamped with the current period, so the delta adds onto it.
|
|
tr := book(t, st, 5, "k/fp", 1100, 1000, now.Add(time.Minute), nil, now.Unix())
|
|
if tr.MonthRx != 800 || tr.TotalRx != 10_100 {
|
|
t.Fatalf("booking after correction wrong: %+v", tr)
|
|
}
|
|
if ok, _ := st.SetTraffic(4242, i64p(1), nil, nil, nil); ok {
|
|
t.Fatal("SetTraffic on unknown agent must be false")
|
|
}
|
|
}
|