package probe import ( "strconv" "testing" "time" ) func strconvFormat(n int64) string { return strconv.FormatInt(n, 10) } func seedMetric(t *testing.T, st *Store, agent, ts int64, cpu, cpuMax float64, mem, netRx, netRxMax, tcpC int64) { t.Helper() _, err := st.db.Exec(` INSERT INTO probe_metric (agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx, tcp, udp, procs, net_rx_max, net_tx_max, cpu_max) VALUES (?, ?, ?, ?, 0, 0, ?, 0, ?, 0, 0, ?, 0, ?)`, agent, ts, cpu, mem, netRx, tcpC, netRxMax, cpuMax) if err != nil { t.Fatal(err) } } func seedPing(t *testing.T, st *Store, agent, ts, task, latency int64) { t.Helper() if _, err := st.db.Exec(`INSERT INTO probe_ping_record (agent_id, ts, task_id, latency) VALUES (?, ?, ?, ?)`, agent, ts, task, latency); err != nil { t.Fatal(err) } } // One closed hour folds into one hourly row per agent: means over the // minute rows (byte columns truncated, counts rounded), peaks maxed. func TestRollUpFoldsHour(t *testing.T) { st := newTestStore(t) now := time.Now().Unix() hourA := hourFloor(now) - 3*3600 // closed and past the late grace seedMetric(t, st, 1, hourA+60, 10, 15, 100, 100, 500, 1) seedMetric(t, st, 1, hourA+120, 20, 25, 200, 100, 500, 2) seedMetric(t, st, 1, hourA+180, 30, 35, 300, 100, 500, 4) seedMetric(t, st, 2, hourA+60, 99, 99, 999, 9, 9, 9) // agent 2, same hour // The most recent hour must not fold yet (late grace). recent := hourFloor(now) - 3600 seedMetric(t, st, 1, recent+60, 77, 77, 777, 7, 7, 7) folded, err := st.RollUp(now, 7) if err != nil { t.Fatal(err) } if folded == 0 { t.Fatal("expected at least one folded hour") } var minutes, mem, netRx, netRxMax, tcpC int64 var cpu, cpuMax float64 err = st.db.QueryRow(` SELECT minutes, cpu, cpu_max, mem_used, net_rx, net_rx_max, tcp FROM probe_metric_hour WHERE agent_id = 1 AND ts = ?`, hourA). Scan(&minutes, &cpu, &cpuMax, &mem, &netRx, &netRxMax, &tcpC) if err != nil { t.Fatal(err) } if minutes != 3 || cpu != 20 || cpuMax != 35 || mem != 200 || netRx != 100 || netRxMax != 500 || tcpC != 2 { t.Fatalf("folded row wrong: minutes=%d cpu=%v cpuMax=%v mem=%d netRx=%d netRxMax=%d tcp=%d", minutes, cpu, cpuMax, mem, netRx, netRxMax, tcpC) } var recentCount int if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric_hour WHERE ts = ?`, recent).Scan(&recentCount); err != nil { t.Fatal(err) } if recentCount != 0 { t.Fatal("recent hour must still be unfolded") } wm, ok, err := st.watermark() if err != nil || !ok { t.Fatalf("watermark: ok=%v err=%v", ok, err) } if wm < hourA+3600 || wm > recent+3600 { t.Fatalf("watermark %d must sit past hour A (%d) and no later than past the recent hour (%d)", wm, hourA, recent) } // Folding again is a rewrite, not a duplicate: same values. if _, err := st.RollUp(now, 7); err != nil { t.Fatal(err) } var count int if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric_hour WHERE agent_id = 1 AND ts = ?`, hourA).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Fatalf("hour rows for A = %d, want 1", count) } } // Ping hours: answered/lost counts, the answered median, and lo/hi; // an all-lost task keeps NULL latency fields. func TestRollUpPingTallies(t *testing.T) { st := newTestStore(t) now := time.Now().Unix() hourA := hourFloor(now) - 3*3600 seedPing(t, st, 1, hourA+10, 7, 10) seedPing(t, st, 1, hourA+20, 7, 20) seedPing(t, st, 1, hourA+30, 7, -1) seedPing(t, st, 1, hourA+40, 7, 40) seedPing(t, st, 1, hourA+10, 8, -1) seedPing(t, st, 1, hourA+20, 8, -1) if _, err := st.RollUp(now, 7); err != nil { t.Fatal(err) } var answered, lost, latency, lo, hi int64 err := st.db.QueryRow(` SELECT answered, lost, latency, lo, hi FROM probe_ping_hour WHERE agent_id = 1 AND ts = ? AND task_id = 7`, hourA).Scan(&answered, &lost, &latency, &lo, &hi) if err != nil { t.Fatal(err) } if answered != 3 || lost != 1 || latency != 20 || lo != 10 || hi != 40 { t.Fatalf("tally wrong: answered=%d lost=%d latency=%d lo=%d hi=%d", answered, lost, latency, lo, hi) } var latNull bool err = st.db.QueryRow(` SELECT latency IS NULL FROM probe_ping_hour WHERE agent_id = 1 AND ts = ? AND task_id = 8`, hourA).Scan(&latNull) if err != nil { t.Fatal(err) } if !latNull { t.Fatal("all-lost task must keep NULL latency") } } // Prune respects the watermark: minute rows past the detail window // survive until their hour is folded; hourly rows past the retention // window go; the traffic ledger is never pruned. func TestPruneBoundaries(t *testing.T) { st := newTestStore(t) now := time.Now() nowU := now.Unix() oldMinute := hourFloor(nowU) - 8*86400 // past the 7-day detail window recentMinute := hourFloor(nowU) - 3600 seedMetric(t, st, 1, oldMinute+60, 1, 1, 1, 1, 1, 1) seedMetric(t, st, 1, recentMinute+60, 1, 1, 1, 1, 1, 1) // Watermark only covers up to oldMinute: the old row is NOT yet // deletable even though it is past the window. if _, err := st.db.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, ?)`, rolledKey, strconvFormat(oldMinute)); err != nil { t.Fatal(err) } if _, err := st.Prune(7); err != nil { t.Fatal(err) } var n int if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric WHERE agent_id = 1`).Scan(&n); err != nil { t.Fatal(err) } if n != 2 { t.Fatalf("unfolded old row must survive prune, rows=%d", n) } // Roll forward past the old row's hour, making the watermark cover // it; prune must now drop it (the recent row stays). if _, err := st.db.Exec(`UPDATE probe_setting SET value = ? WHERE key = ?`, strconvFormat(oldMinute+3600), rolledKey); err != nil { t.Fatal(err) } if _, err := st.Prune(7); err != nil { t.Fatal(err) } if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric WHERE agent_id = 1`).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("folded old row must be pruned, rows=%d", n) } // Hourly rows past retention go; within stay. if _, err := st.db.Exec(`INSERT INTO probe_metric_hour (agent_id, ts, minutes, cpu, cpu_max, mem_used, swap_used, disk_used, net_rx, net_tx, net_rx_max, net_tx_max, tcp, udp, procs) VALUES (1, ?, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)`, hourFloor(nowU)-8*86400); err != nil { t.Fatal(err) } if _, err := st.db.Exec(`INSERT INTO probe_metric_hour (agent_id, ts, minutes, cpu, cpu_max, mem_used, swap_used, disk_used, net_rx, net_tx, net_rx_max, net_tx_max, tcp, udp, procs) VALUES (1, ?, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)`, hourFloor(nowU)-6*86400); err != nil { t.Fatal(err) } if _, err := st.Prune(7); err != nil { t.Fatal(err) } if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric_hour WHERE agent_id = 1`).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("hour rows=%d, want 1 (only the in-window one)", n) } // Traffic bookkeeping is never pruned. book(t, st, 1, "k/fp", 100, 100, now.Add(-30*24*time.Hour), nil, 0) book(t, st, 1, "k/fp", 300, 300, now.Add(-30*24*time.Hour).Add(time.Minute), nil, 0) if _, err := st.Prune(7); err != nil { t.Fatal(err) } tr, ok, _ := st.TrafficFor(1) if !ok || tr.TotalRx != 200 { t.Fatalf("traffic must survive prune: %+v ok=%v", tr, ok) } }