From 8bb89c403112413c30c86f57d6cbbdddf915f54f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 4 Feb 2026 19:56:12 +0000 Subject: [PATCH] test: add memory regression coverage for AI stores --- .../approval_memory_regression_test.go | 108 ++++++++++++++++++ .../ai/chat/session_memory_regression_test.go | 92 +++++++++++++++ .../change_detector_memory_regression_test.go | 77 +++++++++++++ .../remediation_memory_regression_test.go | 73 ++++++++++++ .../patrol_history_memory_regression_test.go | 69 +++++++++++ 5 files changed, 419 insertions(+) create mode 100644 internal/ai/approval/approval_memory_regression_test.go create mode 100644 internal/ai/chat/session_memory_regression_test.go create mode 100644 internal/ai/memory/change_detector_memory_regression_test.go create mode 100644 internal/ai/memory/remediation_memory_regression_test.go create mode 100644 internal/ai/patrol_history_memory_regression_test.go diff --git a/internal/ai/approval/approval_memory_regression_test.go b/internal/ai/approval/approval_memory_regression_test.go new file mode 100644 index 000000000..b616724f8 --- /dev/null +++ b/internal/ai/approval/approval_memory_regression_test.go @@ -0,0 +1,108 @@ +package approval + +import ( + "fmt" + "runtime" + "runtime/debug" + "testing" + "time" +) + +func TestApprovalStoreMemoryStability(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory regression in short mode") + } + + store, err := NewStore(StoreConfig{ + DataDir: t.TempDir(), + DefaultTimeout: 2 * time.Minute, + MaxApprovals: 50, + DisablePersistence: true, + }) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + approvalsPerCycle := 120 + executionsPerCycle := 60 + warmupCycles := 5 + measureCycles := 12 + + runCycle := func(offset int) { + for i := 0; i < approvalsPerCycle; i++ { + idx := offset + i + req := &ApprovalRequest{ + ExecutionID: fmt.Sprintf("exec-%d", idx), + ToolID: "pulse_exec", + Command: "systemctl status nginx", + TargetType: "vm", + TargetID: fmt.Sprintf("vm-%02d", idx%20), + Context: "status check", + } + if err := store.CreateApproval(req); err != nil { + t.Fatalf("CreateApproval: %v", err) + } + + var updated *ApprovalRequest + if idx%2 == 0 { + approved, err := store.Approve(req.ID, "admin") + if err != nil { + t.Fatalf("Approve: %v", err) + } + updated = approved + } else { + denied, err := store.Deny(req.ID, "admin", "not needed") + if err != nil { + t.Fatalf("Deny: %v", err) + } + updated = denied + } + if updated != nil { + decidedAt := time.Now().Add(-48 * time.Hour) + updated.DecidedAt = &decidedAt + } + } + + for i := 0; i < executionsPerCycle; i++ { + idx := offset + i + state := &ExecutionState{ + ID: fmt.Sprintf("exec-state-%d", idx), + CreatedAt: time.Now().Add(-2 * time.Hour), + ExpiresAt: time.Now().Add(-1 * time.Hour), + Messages: []map[string]interface{}{{"role": "user", "content": "status"}}, + OriginalRequest: map[string]interface{}{"prompt": "status"}, + } + if err := store.StoreExecution(state); err != nil { + t.Fatalf("StoreExecution: %v", err) + } + } + + store.CleanupExpired() + } + + for i := 0; i < warmupCycles; i++ { + runCycle(i * approvalsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var baseline runtime.MemStats + runtime.ReadMemStats(&baseline) + + for i := 0; i < measureCycles; i++ { + runCycle(100000 + i*approvalsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var after runtime.MemStats + runtime.ReadMemStats(&after) + + if baseline.HeapAlloc > 0 { + allowed := baseline.HeapAlloc + 5*1024*1024 + growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc) + if after.HeapAlloc > allowed && growthRatio > 1.25 { + t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio) + } + } +} diff --git a/internal/ai/chat/session_memory_regression_test.go b/internal/ai/chat/session_memory_regression_test.go new file mode 100644 index 000000000..310b7c9a9 --- /dev/null +++ b/internal/ai/chat/session_memory_regression_test.go @@ -0,0 +1,92 @@ +package chat + +import ( + "fmt" + "runtime" + "runtime/debug" + "testing" + "time" +) + +func TestSessionStoreMemoryStability(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory regression in short mode") + } + + store, err := NewSessionStore(t.TempDir()) + if err != nil { + t.Fatalf("NewSessionStore: %v", err) + } + + sessionsPerCycle := 40 + messagesPerSession := 6 + warmupCycles := 4 + measureCycles := 10 + + runCycle := func(offset int) { + for i := 0; i < sessionsPerCycle; i++ { + session, err := store.Create() + if err != nil { + t.Fatalf("Create: %v", err) + } + + for j := 0; j < messagesPerSession; j++ { + msg := Message{ + Role: "user", + Content: fmt.Sprintf("message %d", offset+j), + Timestamp: time.Now(), + } + if err := store.AddMessage(session.ID, msg); err != nil { + t.Fatalf("AddMessage: %v", err) + } + } + + store.SetToolSet(session.ID, map[string]bool{"pulse_query": true}) + + res := &ResolvedResource{ + Kind: "vm", + ProviderUID: fmt.Sprintf("vm-%02d", i), + ResourceID: fmt.Sprintf("vm:%02d", i), + Name: fmt.Sprintf("vm-%02d", i), + ResourceType: "vm", + } + store.AddResolvedResource(session.ID, res.Name, res) + + ka := store.GetKnowledgeAccumulator(session.ID) + ka.SetTurn(1) + ka.AddFact(FactCategoryResource, fmt.Sprintf("vm:%02d:status", i), "running") + + store.ClearSessionState(session.ID, false) + + if err := store.Delete(session.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + } + } + + for i := 0; i < warmupCycles; i++ { + runCycle(i * sessionsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var baseline runtime.MemStats + runtime.ReadMemStats(&baseline) + + for i := 0; i < measureCycles; i++ { + runCycle(100000 + i*sessionsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var after runtime.MemStats + runtime.ReadMemStats(&after) + + if baseline.HeapAlloc > 0 { + allowed := baseline.HeapAlloc + 5*1024*1024 + growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc) + if after.HeapAlloc > allowed && growthRatio > 1.25 { + t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio) + } + } +} diff --git a/internal/ai/memory/change_detector_memory_regression_test.go b/internal/ai/memory/change_detector_memory_regression_test.go new file mode 100644 index 000000000..0c6fb6d49 --- /dev/null +++ b/internal/ai/memory/change_detector_memory_regression_test.go @@ -0,0 +1,77 @@ +package memory + +import ( + "fmt" + "runtime" + "runtime/debug" + "testing" + "time" +) + +func TestChangeDetectorMemoryStability(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory regression in short mode") + } + + detector := NewChangeDetector(ChangeDetectorConfig{ + MaxChanges: 50, + }) + + resourcesPerCycle := 120 + warmupCycles := 4 + measureCycles := 12 + + buildSnapshots := func(offset int, status string) []ResourceSnapshot { + now := time.Now() + snapshots := make([]ResourceSnapshot, 0, resourcesPerCycle) + for i := 0; i < resourcesPerCycle; i++ { + snapshots = append(snapshots, ResourceSnapshot{ + ID: fmt.Sprintf("vm-%03d", i), + Name: fmt.Sprintf("vm-%03d", i), + Type: "vm", + Status: status, + Node: fmt.Sprintf("node-%d", i%3), + CPUCores: 2 + ((i + offset) % 4), + MemoryBytes: int64(2+(i+offset)%4) * 1024 * 1024 * 1024, + DiskBytes: int64(20+i%10) * 1024 * 1024 * 1024, + LastBackup: now.Add(-time.Duration((i+offset)%5) * time.Hour), + SnapshotTime: now, + }) + } + return snapshots + } + + for i := 0; i < warmupCycles; i++ { + status := "running" + if i%2 == 1 { + status = "stopped" + } + detector.DetectChanges(buildSnapshots(i*resourcesPerCycle, status)) + } + + runtime.GC() + debug.FreeOSMemory() + var baseline runtime.MemStats + runtime.ReadMemStats(&baseline) + + for i := 0; i < measureCycles; i++ { + status := "running" + if i%2 == 1 { + status = "stopped" + } + detector.DetectChanges(buildSnapshots(100000+i*resourcesPerCycle, status)) + } + + runtime.GC() + debug.FreeOSMemory() + var after runtime.MemStats + runtime.ReadMemStats(&after) + + if baseline.HeapAlloc > 0 { + allowed := baseline.HeapAlloc + 5*1024*1024 + growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc) + if after.HeapAlloc > allowed && growthRatio > 1.25 { + t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio) + } + } +} diff --git a/internal/ai/memory/remediation_memory_regression_test.go b/internal/ai/memory/remediation_memory_regression_test.go new file mode 100644 index 000000000..1b4545e31 --- /dev/null +++ b/internal/ai/memory/remediation_memory_regression_test.go @@ -0,0 +1,73 @@ +package memory + +import ( + "fmt" + "runtime" + "runtime/debug" + "testing" + "time" +) + +func TestRemediationLogMemoryStability(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory regression in short mode") + } + + log := NewRemediationLog(RemediationLogConfig{ + MaxRecords: 80, + }) + + recordsPerCycle := 200 + warmupCycles := 4 + measureCycles := 12 + + recordCycle := func(offset int) { + for i := 0; i < recordsPerCycle; i++ { + idx := offset + i + err := log.Log(RemediationRecord{ + ResourceID: fmt.Sprintf("vm-%02d", idx%20), + ResourceType: "vm", + ResourceName: fmt.Sprintf("vm-%02d", idx%20), + Problem: "memory spike", + Summary: "restart service", + Action: "systemctl restart app", + Output: "ok", + Outcome: OutcomeResolved, + Duration: 2 * time.Second, + Automatic: idx%2 == 0, + Timestamp: time.Now().Add(-time.Duration(idx%60) * time.Second), + }) + if err != nil { + t.Fatalf("Log: %v", err) + } + } + _ = log.GetForResource("vm-01", 5) + _ = log.GetSimilar("memory spike", 5) + } + + for i := 0; i < warmupCycles; i++ { + recordCycle(i * recordsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var baseline runtime.MemStats + runtime.ReadMemStats(&baseline) + + for i := 0; i < measureCycles; i++ { + recordCycle(100000 + i*recordsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var after runtime.MemStats + runtime.ReadMemStats(&after) + + if baseline.HeapAlloc > 0 { + allowed := baseline.HeapAlloc + 5*1024*1024 + growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc) + if after.HeapAlloc > allowed && growthRatio > 1.25 { + t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio) + } + } +} diff --git a/internal/ai/patrol_history_memory_regression_test.go b/internal/ai/patrol_history_memory_regression_test.go new file mode 100644 index 000000000..20bac0c74 --- /dev/null +++ b/internal/ai/patrol_history_memory_regression_test.go @@ -0,0 +1,69 @@ +package ai + +import ( + "fmt" + "runtime" + "runtime/debug" + "testing" + "time" +) + +func TestPatrolRunHistoryMemoryStability(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory regression in short mode") + } + + store := NewPatrolRunHistoryStore(60) + + runsPerCycle := 150 + warmupCycles := 4 + measureCycles := 12 + + addCycle := func(offset int) { + for i := 0; i < runsPerCycle; i++ { + idx := offset + i + started := time.Now().Add(-time.Duration(idx%120) * time.Second) + completed := started.Add(2 * time.Second) + store.Add(PatrolRunRecord{ + ID: fmt.Sprintf("run-%d", idx), + StartedAt: started, + CompletedAt: completed, + Duration: completed.Sub(started), + DurationMs: int64(completed.Sub(started) / time.Millisecond), + Type: "patrol", + TriggerReason: "scheduled", + ResourcesChecked: 10, + NewFindings: idx % 3, + Status: "healthy", + FindingsSummary: "All healthy", + }) + } + _ = store.GetRecent(10) + } + + for i := 0; i < warmupCycles; i++ { + addCycle(i * runsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var baseline runtime.MemStats + runtime.ReadMemStats(&baseline) + + for i := 0; i < measureCycles; i++ { + addCycle(100000 + i*runsPerCycle) + } + + runtime.GC() + debug.FreeOSMemory() + var after runtime.MemStats + runtime.ReadMemStats(&after) + + if baseline.HeapAlloc > 0 { + allowed := baseline.HeapAlloc + 5*1024*1024 + growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc) + if after.HeapAlloc > allowed && growthRatio > 1.25 { + t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio) + } + } +}