Pulse/internal/api/router_bench_test.go
2026-03-18 16:06:30 +00:00

794 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"testing"
"time"
"unsafe"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// suppressBenchLogs disables zerolog for the duration of a benchmark to prevent
// log I/O from skewing results.
func suppressBenchLogs(b *testing.B) {
b.Helper()
orig := log.Logger
log.Logger = zerolog.Nop()
b.Cleanup(func() { log.Logger = orig })
}
// setBenchUnexportedField sets an unexported field on a struct via reflection.
func setBenchUnexportedField(b *testing.B, target interface{}, field string, value interface{}) {
b.Helper()
v := reflect.ValueOf(target).Elem()
f := v.FieldByName(field)
if !f.IsValid() {
b.Fatalf("field %q not found", field)
}
ptr := unsafe.Pointer(f.UnsafeAddr())
reflect.NewAt(f.Type(), ptr).Elem().Set(reflect.ValueOf(value))
}
// newBenchMetricsStore creates an ephemeral metrics store for benchmarks with
// background flushes disabled.
func newBenchMetricsStore(b *testing.B) *metrics.Store {
b.Helper()
dir := b.TempDir()
cfg := metrics.DefaultConfig(dir)
cfg.DBPath = filepath.Join(dir, "bench.db")
cfg.FlushInterval = time.Hour
cfg.WriteBufferSize = 10_000
store, err := metrics.NewStore(cfg)
if err != nil {
b.Fatalf("NewStore: %v", err)
}
b.Cleanup(func() { store.Close() })
return store
}
func syncBenchResourceStore(b *testing.B, monitor *monitoring.Monitor, state *models.State) {
b.Helper()
adapter := unifiedresources.NewMonitorAdapter(nil)
adapter.PopulateFromSnapshot(state.GetSnapshot())
setBenchUnexportedField(b, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter))
}
// seedBenchMetricsMulti writes numResources × numPoints × len(metricTypes)
// metrics to the store. Data spans from now-50min to now, ensuring all points
// fall within a 1h query window.
func seedBenchMetricsMulti(b *testing.B, store *metrics.Store, resourceType string, metricTypes []string, numResources, numPoints int) []string {
b.Helper()
base := time.Now().Add(-50 * time.Minute)
ids := make([]string, numResources)
batch := make([]metrics.WriteMetric, 0, numResources*numPoints*len(metricTypes))
for r := 0; r < numResources; r++ {
id := fmt.Sprintf("%s-bench-%d", resourceType, r)
ids[r] = id
for _, mt := range metricTypes {
for p := 0; p < numPoints; p++ {
batch = append(batch, metrics.WriteMetric{
ResourceType: resourceType,
ResourceID: id,
MetricType: mt,
Value: float64(p % 100),
Timestamp: base.Add(time.Duration(p) * 6 * time.Second),
Tier: metrics.TierRaw,
})
}
}
}
store.WriteBatchSync(batch)
return ids
}
// BenchmarkHandleMetricsHistory_StoreQuery measures end-to-end latency of the
// /api/metrics-store/history handler when serving from the SQLite store — the
// primary hot path for chart rendering. Data is seeded within the last 50
// minutes so all points are returned by both 1h and 24h query windows.
func BenchmarkHandleMetricsHistory_StoreQuery(b *testing.B) {
suppressBenchLogs(b)
store := newBenchMetricsStore(b)
const numPoints = 500
metricTypes := []string{"cpu", "memory", "disk", "netin"}
ids := seedBenchMetricsMulti(b, store, "vm", metricTypes, 10, numPoints)
state := models.NewState()
monitor := &monitoring.Monitor{}
setBenchUnexportedField(b, monitor, "state", state)
setBenchUnexportedField(b, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour))
setBenchUnexportedField(b, monitor, "metricsStore", store)
tempDir := b.TempDir()
mtp := config.NewMultiTenantPersistence(tempDir)
if _, err := mtp.GetPersistence("default"); err != nil {
b.Fatalf("failed to init persistence: %v", err)
}
router := &Router{
monitor: monitor,
licenseHandlers: NewLicenseHandlers(mtp, false),
}
// Sanity check: verify store path returns non-empty data.
req := httptest.NewRequest(http.MethodGet,
"/api/metrics-store/history?resourceType=vm&resourceId="+ids[0]+"&metric=cpu&range=1h", nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("sanity check failed: expected 200, got %d; body: %s", rec.Code, rec.Body.String())
}
var check metricsHistoryResponse
if err := json.Unmarshal(rec.Body.Bytes(), &check); err != nil {
b.Fatalf("sanity check: unmarshal failed: %v", err)
}
if check.Source != "store" {
b.Fatalf("expected source store, got %q", check.Source)
}
if len(check.Points) == 0 {
b.Fatalf("sanity check: expected non-empty points from store, got 0")
}
// Precompute per-resource URLs to avoid string building in timed loops.
urls1h := make([]string, len(ids))
urls24h := make([]string, len(ids))
urlsMaxPts := make([]string, len(ids))
urlsAll := make([]string, len(ids))
for i, id := range ids {
urls1h[i] = "/api/metrics-store/history?resourceType=vm&resourceId=" + id + "&metric=cpu&range=1h"
urls24h[i] = "/api/metrics-store/history?resourceType=vm&resourceId=" + id + "&metric=cpu&range=24h"
urlsMaxPts[i] = "/api/metrics-store/history?resourceType=vm&resourceId=" + id + "&metric=cpu&range=1h&maxPoints=100"
urlsAll[i] = "/api/metrics-store/history?resourceType=vm&resourceId=" + id + "&range=1h"
}
b.Run("single-metric-1h", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, urls1h[i%len(ids)], nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
b.Run("single-metric-24h", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, urls24h[i%len(ids)], nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
b.Run("single-metric-maxPoints-100", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, urlsMaxPts[i%len(ids)], nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
b.Run("all-metrics-1h", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, urlsAll[i%len(ids)], nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
}
// BenchmarkHandleMetricsHistory_MemoryFallback measures latency of the
// in-memory fallback path (used when the SQLite store has no data for the
// requested resource). This path falls through to MetricsHistory and then to
// live state.
func BenchmarkHandleMetricsHistory_MemoryFallback(b *testing.B) {
suppressBenchLogs(b)
state := models.NewState()
vms := make([]models.VM, 10)
for i := range vms {
vms[i] = models.VM{
ID: fmt.Sprintf("pve1:node1:%d", 100+i),
VMID: 100 + i,
Name: fmt.Sprintf("vm-%d", 100+i),
Node: "node1",
Instance: "pve1",
Status: "running",
Type: "qemu",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20)},
Disk: models.Disk{Usage: float64(i%40 + 30)},
}
}
state.UpdateVMsForInstance("pve1", vms)
mh := monitoring.NewMetricsHistory(1000, time.Hour)
now := time.Now()
for _, vm := range vms {
for j := 0; j < 60; j++ {
ts := now.Add(time.Duration(-60+j) * time.Minute)
mh.AddGuestMetric(vm.ID, "cpu", vm.CPU*100+float64(j%10), ts)
mh.AddGuestMetric(vm.ID, "memory", vm.Memory.Usage+float64(j%5), ts)
}
}
monitor := &monitoring.Monitor{}
setBenchUnexportedField(b, monitor, "state", state)
setBenchUnexportedField(b, monitor, "metricsHistory", mh)
router := &Router{monitor: monitor}
// Sanity check: verify memory fallback path returns non-empty data.
req := httptest.NewRequest(http.MethodGet,
"/api/metrics-store/history?resourceType=vm&resourceId=pve1:node1:100&metric=cpu&range=1h", nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("sanity check failed: status %d, body: %s", rec.Code, rec.Body.String())
}
var check metricsHistoryResponse
if err := json.Unmarshal(rec.Body.Bytes(), &check); err != nil {
b.Fatalf("sanity check: unmarshal failed: %v", err)
}
if len(check.Points) == 0 {
b.Fatalf("sanity check: expected non-empty points from memory fallback, got 0")
}
// Precompute URLs.
vmURLsSingle := make([]string, 10)
vmURLsAll := make([]string, 10)
for i := 0; i < 10; i++ {
vmID := fmt.Sprintf("pve1:node1:%d", 100+i)
vmURLsSingle[i] = "/api/metrics-store/history?resourceType=vm&resourceId=" + vmID + "&metric=cpu&range=1h"
vmURLsAll[i] = "/api/metrics-store/history?resourceType=vm&resourceId=" + vmID + "&range=1h"
}
b.Run("single-metric", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, vmURLsSingle[i%10], nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
b.Run("all-metrics", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, vmURLsAll[i%10], nil)
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
}
// BenchmarkHandleWorkloadCharts_StoreBacked measures the workloads charts
// endpoint under a store-backed 4h range. This is the workloads summary
// sparkline hot path and should avoid per-workload N+1 queries.
func BenchmarkHandleWorkloadCharts_StoreBacked(b *testing.B) {
suppressBenchLogs(b)
store := newBenchMetricsStore(b)
monitor := &monitoring.Monitor{}
state := models.NewState()
setBenchUnexportedField(b, monitor, "state", state)
setBenchUnexportedField(b, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour))
setBenchUnexportedField(b, monitor, "metricsStore", store)
const (
vmCount = 30
containerCount = 20
dockerHostCount = 10
containersPerHost = 2
pointsPerMetric = 240
)
base := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second)
nodes := make([]models.Node, 5)
for i := range nodes {
nodes[i] = models.Node{
ID: fmt.Sprintf("node-bench-%d", i),
Name: fmt.Sprintf("node-%d", i),
Instance: "pve1",
Status: "online",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 64 << 30, Used: 32 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 500 << 30, Used: 250 << 30},
}
}
state.UpdateNodesForInstance("pve1", nodes)
vms := make([]models.VM, vmCount)
vmIDs := make([]string, vmCount)
for i := range vms {
vmIDs[i] = fmt.Sprintf("vm-bench-%d", i)
vms[i] = models.VM{
ID: vmIDs[i],
VMID: 100 + i,
Name: fmt.Sprintf("vm-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "qemu",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 4 << 30, Used: 2 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 50 << 30, Used: 25 << 30},
}
}
state.UpdateVMsForInstance("pve1", vms)
containers := make([]models.Container, containerCount)
containerIDs := make([]string, containerCount)
for i := range containers {
containerIDs[i] = fmt.Sprintf("ct-bench-%d", i)
containers[i] = models.Container{
ID: containerIDs[i],
VMID: 200 + i,
Name: fmt.Sprintf("ct-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "lxc",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 2 << 30, Used: 1 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 20 << 30, Used: 10 << 30},
}
}
state.UpdateContainersForInstance("pve1", containers)
dockerHosts := make([]models.DockerHost, dockerHostCount)
dockerContainerIDs := make([]string, 0, dockerHostCount*containersPerHost)
for i := range dockerHosts {
hostID := fmt.Sprintf("docker-host-bench-%d", i)
hostContainers := make([]models.DockerContainer, containersPerHost)
for j := range hostContainers {
containerID := fmt.Sprintf("docker-container-bench-%d-%d", i, j)
dockerContainerIDs = append(dockerContainerIDs, containerID)
hostContainers[j] = models.DockerContainer{
ID: containerID,
Name: fmt.Sprintf("docker-%d-%d", i, j),
State: "running",
Status: "running",
CPUPercent: float64((i+j)%80 + 10),
MemoryPercent: float64((i+j)%60 + 20),
NetInRate: float64((i+j)%50 + 5),
NetOutRate: float64((i+j)%50 + 7),
}
}
dockerHosts[i] = models.DockerHost{
ID: hostID,
AgentID: hostID,
Hostname: nodes[i%len(nodes)].Name,
Runtime: "docker",
Status: "online",
CPUUsage: float64(i%80 + 10),
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 32 << 30, Used: 16 << 30},
Disks: []models.Disk{{Usage: float64(i%40 + 30), Total: 200 << 30, Used: 100 << 30}},
Containers: hostContainers,
}
}
state.DockerHosts = dockerHosts
syncBenchResourceStore(b, monitor, state)
seedSeries := func(resourceType string, ids []string, metricTypes []string) {
batch := make([]metrics.WriteMetric, 0, len(ids)*len(metricTypes)*pointsPerMetric)
for r, id := range ids {
for _, mt := range metricTypes {
for p := 0; p < pointsPerMetric; p++ {
batch = append(batch, metrics.WriteMetric{
ResourceType: resourceType,
ResourceID: id,
MetricType: mt,
Value: float64((r + p) % 100),
Timestamp: base.Add(time.Duration(p) * time.Minute),
Tier: metrics.TierMinute,
})
}
}
}
store.WriteBatchSync(batch)
}
seedSeries("vm", vmIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedSeries("container", containerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedSeries("dockerContainer", dockerContainerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
router := &Router{monitor: monitor}
url := "/api/charts/workloads?range=4h&maxPoints=120"
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
router.handleWorkloadCharts(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("sanity check failed: expected 200, got %d; body: %s", rec.Code, rec.Body.String())
}
var check WorkloadChartsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &check); err != nil {
b.Fatalf("sanity check: unmarshal failed: %v", err)
}
if len(check.ChartData) != vmCount+containerCount {
b.Fatalf("expected %d guest chart entries, got %d", vmCount+containerCount, len(check.ChartData))
}
if len(check.DockerData) != len(dockerContainerIDs) {
b.Fatalf("expected %d docker chart entries, got %d", len(dockerContainerIDs), len(check.DockerData))
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
router.handleWorkloadCharts(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
}
// BenchmarkHandleWorkloadsSummaryCharts_StoreBacked measures the workloads
// summary endpoint under a store-backed 4h range. This path should also avoid
// per-workload N+1 queries while computing aggregate p50/p95 sparklines.
func BenchmarkHandleWorkloadsSummaryCharts_StoreBacked(b *testing.B) {
suppressBenchLogs(b)
store := newBenchMetricsStore(b)
monitor := &monitoring.Monitor{}
state := models.NewState()
setBenchUnexportedField(b, monitor, "state", state)
setBenchUnexportedField(b, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour))
setBenchUnexportedField(b, monitor, "metricsStore", store)
const (
vmCount = 30
containerCount = 20
dockerHostCount = 10
containersPerHost = 2
pointsPerMetric = 240
)
base := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second)
nodes := make([]models.Node, 5)
for i := range nodes {
nodes[i] = models.Node{
ID: fmt.Sprintf("node-summary-bench-%d", i),
Name: fmt.Sprintf("node-%d", i),
Instance: "pve1",
Status: "online",
}
}
state.UpdateNodesForInstance("pve1", nodes)
vms := make([]models.VM, vmCount)
vmIDs := make([]string, vmCount)
for i := range vms {
vmIDs[i] = fmt.Sprintf("vm-summary-bench-%d", i)
vms[i] = models.VM{
ID: vmIDs[i],
VMID: 100 + i,
Name: fmt.Sprintf("vm-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "qemu",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 4 << 30, Used: 2 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 50 << 30, Used: 25 << 30},
}
}
state.UpdateVMsForInstance("pve1", vms)
containers := make([]models.Container, containerCount)
containerIDs := make([]string, containerCount)
for i := range containers {
containerIDs[i] = fmt.Sprintf("ct-summary-bench-%d", i)
containers[i] = models.Container{
ID: containerIDs[i],
VMID: 200 + i,
Name: fmt.Sprintf("ct-%d", i),
Node: nodes[i%len(nodes)].Name,
Instance: "pve1",
Status: "running",
Type: "lxc",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 2 << 30, Used: 1 << 30},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 20 << 30, Used: 10 << 30},
}
}
state.UpdateContainersForInstance("pve1", containers)
dockerHosts := make([]models.DockerHost, dockerHostCount)
dockerContainerIDs := make([]string, 0, dockerHostCount*containersPerHost)
for i := range dockerHosts {
hostID := fmt.Sprintf("docker-host-summary-bench-%d", i)
hostContainers := make([]models.DockerContainer, containersPerHost)
for j := range hostContainers {
containerID := fmt.Sprintf("docker-container-summary-bench-%d-%d", i, j)
dockerContainerIDs = append(dockerContainerIDs, containerID)
hostContainers[j] = models.DockerContainer{
ID: containerID,
Name: fmt.Sprintf("docker-%d-%d", i, j),
State: "running",
Status: "running",
CPUPercent: float64((i+j)%80 + 10),
MemoryPercent: float64((i+j)%60 + 20),
NetInRate: float64((i+j)%50 + 5),
NetOutRate: float64((i+j)%50 + 7),
}
}
dockerHosts[i] = models.DockerHost{
ID: hostID,
AgentID: hostID,
Hostname: nodes[i%len(nodes)].Name,
Runtime: "docker",
Status: "online",
CPUUsage: float64(i%80 + 10),
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 32 << 30, Used: 16 << 30},
Disks: []models.Disk{{Usage: float64(i%40 + 30), Total: 200 << 30, Used: 100 << 30}},
Containers: hostContainers,
}
}
state.DockerHosts = dockerHosts
syncBenchResourceStore(b, monitor, state)
seedSeries := func(resourceType string, ids []string, metricTypes []string) {
batch := make([]metrics.WriteMetric, 0, len(ids)*len(metricTypes)*pointsPerMetric)
for r, id := range ids {
for _, mt := range metricTypes {
for p := 0; p < pointsPerMetric; p++ {
batch = append(batch, metrics.WriteMetric{
ResourceType: resourceType,
ResourceID: id,
MetricType: mt,
Value: float64((r + p) % 100),
Timestamp: base.Add(time.Duration(p) * time.Minute),
Tier: metrics.TierMinute,
})
}
}
}
store.WriteBatchSync(batch)
}
seedSeries("vm", vmIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedSeries("container", containerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
seedSeries("dockerContainer", dockerContainerIDs, []string{"cpu", "memory", "disk", "netin", "netout"})
router := &Router{monitor: monitor}
url := "/api/charts/workloads-summary?range=4h"
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("sanity check failed: expected 200, got %d; body: %s", rec.Code, rec.Body.String())
}
var check WorkloadsSummaryChartsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &check); err != nil {
b.Fatalf("sanity check: unmarshal failed: %v", err)
}
if check.GuestCounts.Total != vmCount+containerCount+len(dockerContainerIDs) {
b.Fatalf("expected %d total guests, got %d", vmCount+containerCount+len(dockerContainerIDs), check.GuestCounts.Total)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
router.handleWorkloadsSummaryCharts(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
}
// BenchmarkHandleMetricsStoreStats measures latency of the /api/metrics-store/stats
// endpoint, which performs lightweight SQLite stat queries.
func BenchmarkHandleMetricsStoreStats(b *testing.B) {
suppressBenchLogs(b)
store := newBenchMetricsStore(b)
seedBenchMetricsMulti(b, store, "node", []string{"cpu"}, 5, 100)
monitor := &monitoring.Monitor{}
setBenchUnexportedField(b, monitor, "state", models.NewState())
setBenchUnexportedField(b, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour))
setBenchUnexportedField(b, monitor, "metricsStore", store)
router := &Router{monitor: monitor}
// Sanity check.
req := httptest.NewRequest(http.MethodGet, "/api/metrics-store/stats", nil)
rec := httptest.NewRecorder()
router.handleMetricsStoreStats(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("sanity check failed: status %d", rec.Code)
}
var statsCheck map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &statsCheck); err != nil {
b.Fatalf("sanity check: unmarshal failed: %v", err)
}
if enabled, _ := statsCheck["enabled"].(bool); !enabled {
b.Fatalf("sanity check: expected enabled=true")
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/metrics-store/stats", nil)
rec := httptest.NewRecorder()
router.handleMetricsStoreStats(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
}
// BenchmarkHandleListResources measures end-to-end latency of the GET
// /api/resources handler with a pre-populated registry. After the first
// request builds the registry, subsequent requests serve from cache (the
// production-realistic hot path). This covers: cache lookup, snapshot
// comparison, filtering, sorting, pagination, pruning, and JSON serialization.
func BenchmarkHandleListResources(b *testing.B) {
suppressBenchLogs(b)
state := models.NewState()
nodes := make([]models.Node, 5)
for i := range nodes {
nodes[i] = models.Node{
ID: fmt.Sprintf("pve1:node%d", i),
Name: fmt.Sprintf("node%d", i),
Instance: "pve1",
Status: "online",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 64 * 1024 * 1024 * 1024, Used: 32 * 1024 * 1024 * 1024},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 500 * 1024 * 1024 * 1024, Used: 250 * 1024 * 1024 * 1024},
}
}
state.UpdateNodesForInstance("pve1", nodes)
vms := make([]models.VM, 50)
for i := range vms {
vms[i] = models.VM{
ID: fmt.Sprintf("pve1:node%d:%d", i%5, 100+i),
VMID: 100 + i,
Name: fmt.Sprintf("vm-%d", 100+i),
Node: fmt.Sprintf("node%d", i%5),
Instance: "pve1",
Status: "running",
Type: "qemu",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 4 * 1024 * 1024 * 1024, Used: 2 * 1024 * 1024 * 1024},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 50 * 1024 * 1024 * 1024, Used: 25 * 1024 * 1024 * 1024},
}
}
state.UpdateVMsForInstance("pve1", vms)
containers := make([]models.Container, 30)
for i := range containers {
containers[i] = models.Container{
ID: fmt.Sprintf("pve1:node%d:%d", i%5, 200+i),
VMID: 200 + i,
Name: fmt.Sprintf("ct-%d", 200+i),
Node: fmt.Sprintf("node%d", i%5),
Instance: "pve1",
Status: "running",
Type: "lxc",
CPU: float64(i%80+10) / 100.0,
Memory: models.Memory{Usage: float64(i%60 + 20), Total: 2 * 1024 * 1024 * 1024, Used: 1 * 1024 * 1024 * 1024},
Disk: models.Disk{Usage: float64(i%40 + 30), Total: 20 * 1024 * 1024 * 1024, Used: 10 * 1024 * 1024 * 1024},
}
}
state.UpdateContainersForInstance("pve1", containers)
tempDir := b.TempDir()
cfg := &config.Config{DataPath: tempDir}
handlers := NewResourceHandlers(cfg)
handlers.SetStateProvider(&benchStateProvider{state: state})
// Sanity check: verify handler returns non-empty resource data.
req := httptest.NewRequest(http.MethodGet, "/api/resources", nil)
rec := httptest.NewRecorder()
handlers.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("sanity check failed: status %d, body: %s", rec.Code, rec.Body.String())
}
var checkResp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &checkResp); err != nil {
b.Fatalf("sanity check: unmarshal failed: %v", err)
}
data, _ := checkResp["data"].([]interface{})
if len(data) == 0 {
b.Fatalf("sanity check: expected resources in response, got none")
}
b.Run("unfiltered", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/resources", nil)
rec := httptest.NewRecorder()
handlers.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
b.Run("filtered-type-vm", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=vm", nil)
rec := httptest.NewRecorder()
handlers.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
b.Run("paginated-page1-limit20", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/resources?page=1&limit=20", nil)
rec := httptest.NewRecorder()
handlers.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
b.Run("sorted-by-cpu", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/resources?sort=cpu&order=desc", nil)
rec := httptest.NewRecorder()
handlers.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
})
}
// benchStateProvider implements StateProvider for benchmarks by returning a
// snapshot of the given State.
type benchStateProvider struct {
state *models.State
}
func (p *benchStateProvider) ReadSnapshot() models.StateSnapshot {
return p.state.GetSnapshot()
}