Pulse/internal/monitoring/metrics_history_test.go
rcourtman c7f4030c29 fix(monitoring): prevent memory leak from stale metrics history and rate tracker entries
MetricsHistory.Cleanup() was defined but never called, and even if called,
it only removed old data points without deleting map entries for deleted
containers/VMs. Each stale entry leaked ~224KB (7 pre-allocated slices).

Changes:
- Call metricsHistory.Cleanup() and rateTracker.Cleanup() in maintenance loop
- Delete map entries entirely when all data points have expired
- Return nil instead of empty slice in cleanupMetrics() to release backing arrays
- Add Cleanup() method to RateTracker with 24-hour stale threshold
- Add debug logging to track cleanup activity

Related to #1153
2026-02-03 17:16:06 +00:00

1213 lines
33 KiB
Go

package monitoring
import (
"testing"
"time"
)
func TestNewMetricsHistory(t *testing.T) {
tests := []struct {
name string
maxDataPoints int
retentionTime time.Duration
}{
{
name: "standard values",
maxDataPoints: 100,
retentionTime: time.Hour,
},
{
name: "zero max points",
maxDataPoints: 0,
retentionTime: time.Minute,
},
{
name: "zero retention",
maxDataPoints: 50,
retentionTime: 0,
},
{
name: "large values",
maxDataPoints: 10000,
retentionTime: 24 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mh := NewMetricsHistory(tt.maxDataPoints, tt.retentionTime)
if mh == nil {
t.Fatal("NewMetricsHistory returned nil")
}
if mh.maxDataPoints != tt.maxDataPoints {
t.Errorf("maxDataPoints = %d, want %d", mh.maxDataPoints, tt.maxDataPoints)
}
if mh.retentionTime != tt.retentionTime {
t.Errorf("retentionTime = %v, want %v", mh.retentionTime, tt.retentionTime)
}
if mh.guestMetrics == nil {
t.Error("guestMetrics map not initialized")
}
if mh.nodeMetrics == nil {
t.Error("nodeMetrics map not initialized")
}
if mh.storageMetrics == nil {
t.Error("storageMetrics map not initialized")
}
})
}
}
func TestAppendMetric(t *testing.T) {
now := time.Now()
tests := []struct {
name string
maxDataPoints int
retentionTime time.Duration
existing []MetricPoint
newPoint MetricPoint
wantLen int
wantFirst float64 // value of first point after append
wantLast float64 // value of last point after append
}{
{
name: "append to empty slice",
maxDataPoints: 10,
retentionTime: time.Hour,
existing: []MetricPoint{},
newPoint: MetricPoint{Value: 50.0, Timestamp: now},
wantLen: 1,
wantFirst: 50.0,
wantLast: 50.0,
},
{
name: "append within limits",
maxDataPoints: 10,
retentionTime: time.Hour,
existing: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-30 * time.Minute)},
{Value: 20.0, Timestamp: now.Add(-20 * time.Minute)},
},
newPoint: MetricPoint{Value: 30.0, Timestamp: now},
wantLen: 3,
wantFirst: 10.0,
wantLast: 30.0,
},
{
name: "exceed max data points",
maxDataPoints: 3,
retentionTime: time.Hour,
existing: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-30 * time.Minute)},
{Value: 20.0, Timestamp: now.Add(-20 * time.Minute)},
{Value: 30.0, Timestamp: now.Add(-10 * time.Minute)},
},
newPoint: MetricPoint{Value: 40.0, Timestamp: now},
wantLen: 3,
wantFirst: 20.0, // oldest point dropped
wantLast: 40.0,
},
{
name: "old points beyond retention removed",
maxDataPoints: 100,
retentionTime: 30 * time.Minute,
existing: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-2 * time.Hour)}, // beyond retention
{Value: 20.0, Timestamp: now.Add(-90 * time.Minute)}, // beyond retention
{Value: 30.0, Timestamp: now.Add(-20 * time.Minute)}, // within retention
},
newPoint: MetricPoint{Value: 40.0, Timestamp: now},
wantLen: 2,
wantFirst: 30.0, // old points removed
wantLast: 40.0,
},
{
name: "all old points removed except new",
maxDataPoints: 100,
retentionTime: 10 * time.Minute,
existing: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-2 * time.Hour)},
{Value: 20.0, Timestamp: now.Add(-1 * time.Hour)},
},
newPoint: MetricPoint{Value: 30.0, Timestamp: now},
wantLen: 1,
wantFirst: 30.0,
wantLast: 30.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mh := NewMetricsHistory(tt.maxDataPoints, tt.retentionTime)
result := mh.appendMetric(tt.existing, tt.newPoint)
if len(result) != tt.wantLen {
t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen)
}
if len(result) > 0 {
if result[0].Value != tt.wantFirst {
t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst)
}
if result[len(result)-1].Value != tt.wantLast {
t.Errorf("last value = %v, want %v", result[len(result)-1].Value, tt.wantLast)
}
}
})
}
}
func TestCleanupMetrics(t *testing.T) {
now := time.Now()
tests := []struct {
name string
metrics []MetricPoint
cutoffTime time.Time
wantLen int
wantFirst float64
}{
{
name: "empty slice",
metrics: []MetricPoint{},
cutoffTime: now.Add(-time.Hour),
wantLen: 0,
},
{
name: "no points to remove",
metrics: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-30 * time.Minute)},
{Value: 20.0, Timestamp: now.Add(-20 * time.Minute)},
{Value: 30.0, Timestamp: now.Add(-10 * time.Minute)},
},
cutoffTime: now.Add(-time.Hour),
wantLen: 3,
wantFirst: 10.0,
},
{
name: "remove some old points",
metrics: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-2 * time.Hour)},
{Value: 20.0, Timestamp: now.Add(-90 * time.Minute)},
{Value: 30.0, Timestamp: now.Add(-30 * time.Minute)},
{Value: 40.0, Timestamp: now.Add(-10 * time.Minute)},
},
cutoffTime: now.Add(-time.Hour),
wantLen: 2,
wantFirst: 30.0,
},
{
name: "remove all points",
metrics: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-3 * time.Hour)},
{Value: 20.0, Timestamp: now.Add(-2 * time.Hour)},
},
cutoffTime: now.Add(-time.Hour),
wantLen: 0,
},
{
name: "boundary - point exactly at cutoff",
metrics: []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-time.Hour)}, // exactly at cutoff (not after)
{Value: 20.0, Timestamp: now.Add(-30 * time.Minute)},
},
cutoffTime: now.Add(-time.Hour),
wantLen: 1,
wantFirst: 20.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
result := mh.cleanupMetrics(tt.metrics, tt.cutoffTime)
if len(result) != tt.wantLen {
t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen)
}
if len(result) > 0 && tt.wantFirst != 0 {
if result[0].Value != tt.wantFirst {
t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst)
}
}
})
}
}
func TestAddGuestMetric(t *testing.T) {
now := time.Now()
tests := []struct {
name string
guestID string
metricType string
value float64
}{
{name: "cpu metric", guestID: "vm-100", metricType: "cpu", value: 75.5},
{name: "memory metric", guestID: "vm-100", metricType: "memory", value: 80.0},
{name: "disk metric", guestID: "ct-101", metricType: "disk", value: 50.0},
{name: "diskread metric", guestID: "vm-102", metricType: "diskread", value: 100.5},
{name: "diskwrite metric", guestID: "vm-102", metricType: "diskwrite", value: 50.25},
{name: "netin metric", guestID: "vm-103", metricType: "netin", value: 1000.0},
{name: "netout metric", guestID: "vm-103", metricType: "netout", value: 500.0},
{name: "unknown metric type", guestID: "vm-104", metricType: "unknown", value: 99.9},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
mh.AddGuestMetric(tt.guestID, tt.metricType, tt.value, now)
// Verify guest was created
mh.mu.RLock()
defer mh.mu.RUnlock()
metrics, exists := mh.guestMetrics[tt.guestID]
if !exists {
t.Fatal("guest metrics not created")
}
// Verify correct metric type was populated
var slice []MetricPoint
switch tt.metricType {
case "cpu":
slice = metrics.CPU
case "memory":
slice = metrics.Memory
case "disk":
slice = metrics.Disk
case "diskread":
slice = metrics.DiskRead
case "diskwrite":
slice = metrics.DiskWrite
case "netin":
slice = metrics.NetworkIn
case "netout":
slice = metrics.NetworkOut
default:
// Unknown types should not populate any slice
if len(metrics.CPU)+len(metrics.Memory)+len(metrics.Disk)+
len(metrics.DiskRead)+len(metrics.DiskWrite)+
len(metrics.NetworkIn)+len(metrics.NetworkOut) != 0 {
t.Error("unknown metric type populated a slice")
}
return
}
if len(slice) != 1 {
t.Errorf("slice length = %d, want 1", len(slice))
} else if slice[0].Value != tt.value {
t.Errorf("value = %v, want %v", slice[0].Value, tt.value)
}
})
}
}
func TestAddNodeMetric(t *testing.T) {
now := time.Now()
tests := []struct {
name string
nodeID string
metricType string
value float64
shouldAdd bool
}{
{name: "cpu metric", nodeID: "node1", metricType: "cpu", value: 45.5, shouldAdd: true},
{name: "memory metric", nodeID: "node1", metricType: "memory", value: 60.0, shouldAdd: true},
{name: "disk metric", nodeID: "node2", metricType: "disk", value: 70.0, shouldAdd: true},
{name: "unknown metric type", nodeID: "node3", metricType: "netin", value: 100.0, shouldAdd: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
mh.AddNodeMetric(tt.nodeID, tt.metricType, tt.value, now)
mh.mu.RLock()
defer mh.mu.RUnlock()
metrics, exists := mh.nodeMetrics[tt.nodeID]
if !exists {
t.Fatal("node metrics not created")
}
var slice []MetricPoint
switch tt.metricType {
case "cpu":
slice = metrics.CPU
case "memory":
slice = metrics.Memory
case "disk":
slice = metrics.Disk
default:
if tt.shouldAdd {
t.Error("expected metric to be added")
}
return
}
if tt.shouldAdd {
if len(slice) != 1 {
t.Errorf("slice length = %d, want 1", len(slice))
} else if slice[0].Value != tt.value {
t.Errorf("value = %v, want %v", slice[0].Value, tt.value)
}
}
})
}
}
func TestAddStorageMetric(t *testing.T) {
now := time.Now()
tests := []struct {
name string
storageID string
metricType string
value float64
shouldAdd bool
}{
{name: "usage metric", storageID: "local-lvm", metricType: "usage", value: 45.5, shouldAdd: true},
{name: "used metric", storageID: "local-lvm", metricType: "used", value: 100e9, shouldAdd: true},
{name: "total metric", storageID: "ceph-pool", metricType: "total", value: 500e9, shouldAdd: true},
{name: "avail metric", storageID: "ceph-pool", metricType: "avail", value: 400e9, shouldAdd: true},
{name: "unknown metric type", storageID: "nfs-share", metricType: "iops", value: 1000.0, shouldAdd: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
mh.AddStorageMetric(tt.storageID, tt.metricType, tt.value, now)
mh.mu.RLock()
defer mh.mu.RUnlock()
metrics, exists := mh.storageMetrics[tt.storageID]
if !exists {
t.Fatal("storage metrics not created")
}
var slice []MetricPoint
switch tt.metricType {
case "usage":
slice = metrics.Usage
case "used":
slice = metrics.Used
case "total":
slice = metrics.Total
case "avail":
slice = metrics.Avail
default:
if tt.shouldAdd {
t.Error("expected metric to be added")
}
return
}
if tt.shouldAdd {
if len(slice) != 1 {
t.Errorf("slice length = %d, want 1", len(slice))
} else if slice[0].Value != tt.value {
t.Errorf("value = %v, want %v", slice[0].Value, tt.value)
}
}
})
}
}
func TestGetGuestMetrics(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, time.Hour)
// Add test data
mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-50*time.Minute))
mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-40*time.Minute))
mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-30*time.Minute))
mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-20*time.Minute))
mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-10*time.Minute))
mh.AddGuestMetric("vm-100", "memory", 80.0, now.Add(-5*time.Minute))
tests := []struct {
name string
guestID string
metricType string
duration time.Duration
wantLen int
wantFirst float64
}{
{
name: "get all cpu within hour",
guestID: "vm-100",
metricType: "cpu",
duration: time.Hour,
wantLen: 5,
wantFirst: 10.0,
},
{
name: "get recent cpu only",
guestID: "vm-100",
metricType: "cpu",
duration: 25 * time.Minute,
wantLen: 2,
wantFirst: 40.0,
},
{
name: "get memory",
guestID: "vm-100",
metricType: "memory",
duration: time.Hour,
wantLen: 1,
wantFirst: 80.0,
},
{
name: "nonexistent guest",
guestID: "vm-999",
metricType: "cpu",
duration: time.Hour,
wantLen: 0,
},
{
name: "nonexistent metric type",
guestID: "vm-100",
metricType: "invalid",
duration: time.Hour,
wantLen: 0,
},
{
name: "zero duration returns nothing",
guestID: "vm-100",
metricType: "cpu",
duration: 0,
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mh.GetGuestMetrics(tt.guestID, tt.metricType, tt.duration)
if len(result) != tt.wantLen {
t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen)
}
if len(result) > 0 && tt.wantFirst != 0 {
if result[0].Value != tt.wantFirst {
t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst)
}
}
})
}
}
func TestGetGuestMetrics_AllMetricTypes(t *testing.T) {
now := time.Now()
tests := []struct {
name string
metricType string
value float64
}{
{name: "cpu metric", metricType: "cpu", value: 50.5},
{name: "memory metric", metricType: "memory", value: 70.0},
{name: "disk metric", metricType: "disk", value: 45.0},
{name: "diskread metric", metricType: "diskread", value: 1024.0},
{name: "diskwrite metric", metricType: "diskwrite", value: 512.0},
{name: "netin metric", metricType: "netin", value: 2048.0},
{name: "netout metric", metricType: "netout", value: 1536.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
mh.AddGuestMetric("vm-test", tt.metricType, tt.value, now.Add(-5*time.Minute))
result := mh.GetGuestMetrics("vm-test", tt.metricType, time.Hour)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].Value != tt.value {
t.Errorf("value = %v, want %v", result[0].Value, tt.value)
}
})
}
}
func TestGetGuestMetrics_GuestNotFound(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
// Add data for one guest
mh.AddGuestMetric("vm-100", "cpu", 50.0, time.Now())
// Query non-existent guest
result := mh.GetGuestMetrics("vm-nonexistent", "cpu", time.Hour)
if len(result) != 0 {
t.Errorf("expected empty slice for non-existent guest, got %d elements", len(result))
}
if result == nil {
t.Error("expected empty slice, got nil")
}
}
func TestGetGuestMetrics_UnknownMetricType(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, time.Hour)
// Add data for the guest
mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-5*time.Minute))
mh.AddGuestMetric("vm-100", "memory", 70.0, now.Add(-5*time.Minute))
unknownTypes := []string{"invalid", "unknown", "foo", "bar", "CPU", "Memory", ""}
for _, metricType := range unknownTypes {
t.Run("type_"+metricType, func(t *testing.T) {
result := mh.GetGuestMetrics("vm-100", metricType, time.Hour)
if len(result) != 0 {
t.Errorf("expected empty slice for unknown metric type %q, got %d elements", metricType, len(result))
}
if result == nil {
t.Errorf("expected empty slice for unknown metric type %q, got nil", metricType)
}
})
}
}
func TestGetGuestMetrics_DurationFiltering(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, 2*time.Hour)
// Add points at different times
mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-90*time.Minute)) // old
mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-60*time.Minute)) // old
mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-30*time.Minute)) // recent
mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-15*time.Minute)) // recent
mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-5*time.Minute)) // recent
tests := []struct {
name string
duration time.Duration
wantLen int
wantFirst float64
wantLast float64
}{
{
name: "all points within 2 hours",
duration: 2 * time.Hour,
wantLen: 5,
wantFirst: 10.0,
wantLast: 50.0,
},
{
name: "points within 45 minutes",
duration: 45 * time.Minute,
wantLen: 3,
wantFirst: 30.0,
wantLast: 50.0,
},
{
name: "points within 20 minutes",
duration: 20 * time.Minute,
wantLen: 2,
wantFirst: 40.0,
wantLast: 50.0,
},
{
name: "points within 10 minutes",
duration: 10 * time.Minute,
wantLen: 1,
wantFirst: 50.0,
wantLast: 50.0,
},
{
name: "zero duration excludes all",
duration: 0,
wantLen: 0,
},
{
name: "very short duration excludes all",
duration: 1 * time.Minute,
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mh.GetGuestMetrics("vm-100", "cpu", tt.duration)
if len(result) != tt.wantLen {
t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen)
}
if len(result) > 0 {
if result[0].Value != tt.wantFirst {
t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst)
}
if result[len(result)-1].Value != tt.wantLast {
t.Errorf("last value = %v, want %v", result[len(result)-1].Value, tt.wantLast)
}
}
})
}
}
func TestGetGuestMetrics_EmptyMetricsData(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
// Directly populate an empty guest metrics entry
mh.mu.Lock()
mh.guestMetrics["vm-empty"] = &GuestMetrics{
CPU: []MetricPoint{},
Memory: []MetricPoint{},
Disk: []MetricPoint{},
DiskRead: []MetricPoint{},
DiskWrite: []MetricPoint{},
NetworkIn: []MetricPoint{},
NetworkOut: []MetricPoint{},
}
mh.mu.Unlock()
metricTypes := []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"}
for _, metricType := range metricTypes {
t.Run(metricType, func(t *testing.T) {
result := mh.GetGuestMetrics("vm-empty", metricType, time.Hour)
if len(result) != 0 {
t.Errorf("expected empty slice for empty %s metrics, got %d elements", metricType, len(result))
}
if result == nil {
t.Errorf("expected empty slice for empty %s metrics, got nil", metricType)
}
})
}
}
func TestGetNodeMetrics(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, time.Hour)
// Add test data
mh.AddNodeMetric("node1", "cpu", 25.0, now.Add(-45*time.Minute))
mh.AddNodeMetric("node1", "cpu", 35.0, now.Add(-30*time.Minute))
mh.AddNodeMetric("node1", "cpu", 45.0, now.Add(-15*time.Minute))
mh.AddNodeMetric("node1", "memory", 60.0, now.Add(-10*time.Minute))
mh.AddNodeMetric("node1", "disk", 75.0, now.Add(-5*time.Minute))
tests := []struct {
name string
nodeID string
metricType string
duration time.Duration
wantLen int
}{
{
name: "get all cpu",
nodeID: "node1",
metricType: "cpu",
duration: time.Hour,
wantLen: 3,
},
{
name: "get recent cpu",
nodeID: "node1",
metricType: "cpu",
duration: 20 * time.Minute,
wantLen: 1,
},
{
name: "get memory",
nodeID: "node1",
metricType: "memory",
duration: time.Hour,
wantLen: 1,
},
{
name: "get disk",
nodeID: "node1",
metricType: "disk",
duration: time.Hour,
wantLen: 1,
},
{
name: "nonexistent node",
nodeID: "node999",
metricType: "cpu",
duration: time.Hour,
wantLen: 0,
},
{
name: "invalid metric type",
nodeID: "node1",
metricType: "netin",
duration: time.Hour,
wantLen: 0,
},
{
name: "zero duration returns nothing",
nodeID: "node1",
metricType: "cpu",
duration: 0,
wantLen: 0,
},
{
name: "empty nodeID",
nodeID: "",
metricType: "cpu",
duration: time.Hour,
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mh.GetNodeMetrics(tt.nodeID, tt.metricType, tt.duration)
if len(result) != tt.wantLen {
t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen)
}
})
}
}
func TestGetNodeMetrics_UnknownMetricTypes(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, time.Hour)
// Add data for the node
mh.AddNodeMetric("node1", "cpu", 50.0, now.Add(-5*time.Minute))
mh.AddNodeMetric("node1", "memory", 70.0, now.Add(-5*time.Minute))
mh.AddNodeMetric("node1", "disk", 40.0, now.Add(-5*time.Minute))
unknownTypes := []string{"invalid", "unknown", "netin", "netout", "diskread", "CPU", "Memory", "Disk", ""}
for _, metricType := range unknownTypes {
t.Run("type_"+metricType, func(t *testing.T) {
result := mh.GetNodeMetrics("node1", metricType, time.Hour)
if len(result) != 0 {
t.Errorf("expected empty slice for unknown metric type %q, got %d elements", metricType, len(result))
}
if result == nil {
t.Errorf("expected empty slice for unknown metric type %q, got nil", metricType)
}
})
}
}
func TestGetNodeMetrics_EmptyMetricsData(t *testing.T) {
mh := NewMetricsHistory(100, time.Hour)
// Directly populate an empty node metrics entry (nodes use GuestMetrics type)
mh.mu.Lock()
mh.nodeMetrics["node-empty"] = &GuestMetrics{
CPU: []MetricPoint{},
Memory: []MetricPoint{},
Disk: []MetricPoint{},
}
mh.mu.Unlock()
metricTypes := []string{"cpu", "memory", "disk"}
for _, metricType := range metricTypes {
t.Run(metricType, func(t *testing.T) {
result := mh.GetNodeMetrics("node-empty", metricType, time.Hour)
if len(result) != 0 {
t.Errorf("expected empty slice for empty %s metrics, got %d elements", metricType, len(result))
}
if result == nil {
t.Errorf("expected empty slice for empty %s metrics, got nil", metricType)
}
})
}
}
func TestGetNodeMetrics_DurationFiltering(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, 2*time.Hour)
// Add points at different times
mh.AddNodeMetric("node1", "cpu", 10.0, now.Add(-90*time.Minute)) // old
mh.AddNodeMetric("node1", "cpu", 20.0, now.Add(-60*time.Minute)) // old
mh.AddNodeMetric("node1", "cpu", 30.0, now.Add(-30*time.Minute)) // recent
mh.AddNodeMetric("node1", "cpu", 40.0, now.Add(-15*time.Minute)) // recent
mh.AddNodeMetric("node1", "cpu", 50.0, now.Add(-5*time.Minute)) // recent
tests := []struct {
name string
duration time.Duration
wantLen int
wantFirst float64
wantLast float64
}{
{
name: "all points within 2 hours",
duration: 2 * time.Hour,
wantLen: 5,
wantFirst: 10.0,
wantLast: 50.0,
},
{
name: "points within 45 minutes",
duration: 45 * time.Minute,
wantLen: 3,
wantFirst: 30.0,
wantLast: 50.0,
},
{
name: "points within 20 minutes",
duration: 20 * time.Minute,
wantLen: 2,
wantFirst: 40.0,
wantLast: 50.0,
},
{
name: "points within 10 minutes",
duration: 10 * time.Minute,
wantLen: 1,
wantFirst: 50.0,
wantLast: 50.0,
},
{
name: "zero duration excludes all",
duration: 0,
wantLen: 0,
},
{
name: "very short duration excludes all",
duration: 1 * time.Minute,
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mh.GetNodeMetrics("node1", "cpu", tt.duration)
if len(result) != tt.wantLen {
t.Errorf("len(result) = %d, want %d", len(result), tt.wantLen)
}
if len(result) > 0 {
if result[0].Value != tt.wantFirst {
t.Errorf("first value = %v, want %v", result[0].Value, tt.wantFirst)
}
if result[len(result)-1].Value != tt.wantLast {
t.Errorf("last value = %v, want %v", result[len(result)-1].Value, tt.wantLast)
}
}
})
}
}
func TestGetAllGuestMetrics(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, time.Hour)
// Add test data for multiple metric types
mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-30*time.Minute))
mh.AddGuestMetric("vm-100", "memory", 70.0, now.Add(-25*time.Minute))
mh.AddGuestMetric("vm-100", "disk", 40.0, now.Add(-20*time.Minute))
mh.AddGuestMetric("vm-100", "diskread", 100.0, now.Add(-15*time.Minute))
mh.AddGuestMetric("vm-100", "diskwrite", 50.0, now.Add(-10*time.Minute))
mh.AddGuestMetric("vm-100", "netin", 1000.0, now.Add(-5*time.Minute))
mh.AddGuestMetric("vm-100", "netout", 500.0, now.Add(-1*time.Minute))
tests := []struct {
name string
guestID string
duration time.Duration
wantKeys []string
}{
{
name: "get all metrics within hour",
guestID: "vm-100",
duration: time.Hour,
wantKeys: []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"},
},
{
name: "nonexistent guest",
guestID: "vm-999",
duration: time.Hour,
wantKeys: []string{},
},
{
name: "short duration filters out old",
guestID: "vm-100",
duration: 10 * time.Minute,
wantKeys: []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"}, // keys exist but may be empty
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mh.GetAllGuestMetrics(tt.guestID, tt.duration)
if tt.guestID == "vm-999" {
if len(result) != 0 {
t.Errorf("expected empty result for nonexistent guest")
}
return
}
// Check that expected keys exist
for _, key := range tt.wantKeys {
if _, exists := result[key]; !exists {
t.Errorf("missing key: %s", key)
}
}
})
}
}
func TestGetAllStorageMetrics(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, time.Hour)
// Add test data
mh.AddStorageMetric("local-lvm", "usage", 45.0, now.Add(-30*time.Minute))
mh.AddStorageMetric("local-lvm", "used", 100e9, now.Add(-25*time.Minute))
mh.AddStorageMetric("local-lvm", "total", 200e9, now.Add(-20*time.Minute))
mh.AddStorageMetric("local-lvm", "avail", 100e9, now.Add(-15*time.Minute))
tests := []struct {
name string
storageID string
duration time.Duration
wantKeys []string
}{
{
name: "get all metrics",
storageID: "local-lvm",
duration: time.Hour,
wantKeys: []string{"usage", "used", "total", "avail"},
},
{
name: "nonexistent storage",
storageID: "nonexistent",
duration: time.Hour,
wantKeys: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mh.GetAllStorageMetrics(tt.storageID, tt.duration)
if len(tt.wantKeys) == 0 {
if len(result) != 0 {
t.Errorf("expected empty result")
}
return
}
for _, key := range tt.wantKeys {
if _, exists := result[key]; !exists {
t.Errorf("missing key: %s", key)
}
}
})
}
}
func TestCleanup(t *testing.T) {
now := time.Now()
retentionTime := 30 * time.Minute
mh := NewMetricsHistory(100, retentionTime)
// Add old data (beyond retention)
mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-2*time.Hour))
mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-90*time.Minute))
// Add recent data (within retention)
mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-20*time.Minute))
mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-10*time.Minute))
// Add node metrics
mh.AddNodeMetric("node1", "cpu", 50.0, now.Add(-2*time.Hour))
mh.AddNodeMetric("node1", "cpu", 60.0, now.Add(-10*time.Minute))
// Add storage metrics
mh.AddStorageMetric("local", "usage", 70.0, now.Add(-2*time.Hour))
mh.AddStorageMetric("local", "usage", 80.0, now.Add(-10*time.Minute))
// Run cleanup
mh.Cleanup()
// Verify old data was removed
mh.mu.RLock()
defer mh.mu.RUnlock()
// Check guest metrics
guestMetrics := mh.guestMetrics["vm-100"]
if len(guestMetrics.CPU) != 2 {
t.Errorf("guest CPU metrics count = %d, want 2", len(guestMetrics.CPU))
}
// Check node metrics
nodeMetrics := mh.nodeMetrics["node1"]
if len(nodeMetrics.CPU) != 1 {
t.Errorf("node CPU metrics count = %d, want 1", len(nodeMetrics.CPU))
}
// Check storage metrics
storageMetrics := mh.storageMetrics["local"]
if len(storageMetrics.Usage) != 1 {
t.Errorf("storage usage metrics count = %d, want 1", len(storageMetrics.Usage))
}
}
func TestMultipleGuestsIsolation(t *testing.T) {
now := time.Now()
mh := NewMetricsHistory(100, time.Hour)
// Add metrics for different guests
mh.AddGuestMetric("vm-100", "cpu", 50.0, now)
mh.AddGuestMetric("vm-101", "cpu", 60.0, now)
mh.AddGuestMetric("vm-102", "cpu", 70.0, now)
// Verify isolation
result100 := mh.GetGuestMetrics("vm-100", "cpu", time.Hour)
result101 := mh.GetGuestMetrics("vm-101", "cpu", time.Hour)
result102 := mh.GetGuestMetrics("vm-102", "cpu", time.Hour)
if len(result100) != 1 || result100[0].Value != 50.0 {
t.Errorf("vm-100 metrics incorrect")
}
if len(result101) != 1 || result101[0].Value != 60.0 {
t.Errorf("vm-101 metrics incorrect")
}
if len(result102) != 1 || result102[0].Value != 70.0 {
t.Errorf("vm-102 metrics incorrect")
}
}
func TestMaxDataPointsEnforced(t *testing.T) {
now := time.Now()
maxPoints := 5
mh := NewMetricsHistory(maxPoints, time.Hour)
// Add more points than maxDataPoints
for i := 0; i < 10; i++ {
mh.AddGuestMetric("vm-100", "cpu", float64(i*10), now.Add(-time.Duration(10-i)*time.Minute))
}
result := mh.GetGuestMetrics("vm-100", "cpu", time.Hour)
if len(result) != maxPoints {
t.Errorf("got %d points, want %d", len(result), maxPoints)
}
// Verify we have the most recent points (values 50-90)
if result[0].Value != 50.0 {
t.Errorf("first value = %v, want 50.0 (most recent kept)", result[0].Value)
}
if result[len(result)-1].Value != 90.0 {
t.Errorf("last value = %v, want 90.0", result[len(result)-1].Value)
}
}
func TestRetentionTimeEnforced(t *testing.T) {
now := time.Now()
retentionTime := 30 * time.Minute
mh := NewMetricsHistory(100, retentionTime)
// Add points at various times
mh.AddGuestMetric("vm-100", "cpu", 10.0, now.Add(-2*time.Hour)) // beyond retention
mh.AddGuestMetric("vm-100", "cpu", 20.0, now.Add(-1*time.Hour)) // beyond retention
mh.AddGuestMetric("vm-100", "cpu", 30.0, now.Add(-45*time.Minute)) // beyond retention
mh.AddGuestMetric("vm-100", "cpu", 40.0, now.Add(-20*time.Minute)) // within retention
mh.AddGuestMetric("vm-100", "cpu", 50.0, now.Add(-10*time.Minute)) // within retention
mh.AddGuestMetric("vm-100", "cpu", 60.0, now) // within retention
// appendMetric removes old points on each add
result := mh.GetGuestMetrics("vm-100", "cpu", time.Hour)
// Due to appendMetric behavior, only recent points should remain
if len(result) > 3 {
t.Errorf("got %d points, expected <= 3 recent points after retention enforcement", len(result))
}
}
func TestCleanupRemovesStaleEntries(t *testing.T) {
now := time.Now()
retentionTime := 30 * time.Minute
mh := NewMetricsHistory(100, retentionTime)
// Add data for three guests:
// - "active-guest" has recent data
// - "stale-guest" has only old data (will be removed)
// - "mixed-guest" has both old and recent data
mh.AddGuestMetric("active-guest", "cpu", 50.0, now.Add(-10*time.Minute))
mh.AddGuestMetric("active-guest", "memory", 60.0, now.Add(-10*time.Minute))
mh.AddGuestMetric("stale-guest", "cpu", 30.0, now.Add(-2*time.Hour))
mh.AddGuestMetric("stale-guest", "memory", 40.0, now.Add(-2*time.Hour))
mh.AddGuestMetric("mixed-guest", "cpu", 10.0, now.Add(-2*time.Hour)) // old, will be removed
mh.AddGuestMetric("mixed-guest", "cpu", 70.0, now.Add(-5*time.Minute))
// Add similar for nodes and storage
mh.AddNodeMetric("active-node", "cpu", 50.0, now.Add(-10*time.Minute))
mh.AddNodeMetric("stale-node", "cpu", 30.0, now.Add(-2*time.Hour))
mh.AddStorageMetric("active-storage", "usage", 50.0, now.Add(-10*time.Minute))
mh.AddStorageMetric("stale-storage", "usage", 30.0, now.Add(-2*time.Hour))
// Run cleanup
mh.Cleanup()
// Verify stale entries were removed
mh.mu.RLock()
defer mh.mu.RUnlock()
// active-guest should still exist
if _, exists := mh.guestMetrics["active-guest"]; !exists {
t.Error("active-guest should still exist after cleanup")
}
// stale-guest should be removed (all data expired)
if _, exists := mh.guestMetrics["stale-guest"]; exists {
t.Error("stale-guest should be removed after cleanup")
}
// mixed-guest should still exist (has recent data)
if _, exists := mh.guestMetrics["mixed-guest"]; !exists {
t.Error("mixed-guest should still exist after cleanup")
}
// Verify node entries
if _, exists := mh.nodeMetrics["active-node"]; !exists {
t.Error("active-node should still exist after cleanup")
}
if _, exists := mh.nodeMetrics["stale-node"]; exists {
t.Error("stale-node should be removed after cleanup")
}
// Verify storage entries
if _, exists := mh.storageMetrics["active-storage"]; !exists {
t.Error("active-storage should still exist after cleanup")
}
if _, exists := mh.storageMetrics["stale-storage"]; exists {
t.Error("stale-storage should be removed after cleanup")
}
}
func TestCleanupMetricsReturnsNilForExpiredData(t *testing.T) {
retentionTime := 30 * time.Minute
mh := NewMetricsHistory(100, retentionTime)
now := time.Now()
cutoff := now.Add(-retentionTime)
// Create slice with only old data
oldData := []MetricPoint{
{Value: 10.0, Timestamp: now.Add(-2 * time.Hour)},
{Value: 20.0, Timestamp: now.Add(-1 * time.Hour)},
}
result := mh.cleanupMetrics(oldData, cutoff)
// Should return nil (not empty slice) to release backing array
if result != nil {
t.Errorf("cleanupMetrics should return nil for fully expired data, got slice with len=%d cap=%d", len(result), cap(result))
}
}