From 1b8bb4e91c905de19931634ab79e9e95960f811a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 6 Aug 2026 00:17:52 +0100 Subject: [PATCH] fix(mock): derive host-relative memory history beyond the seeded window Mock history is seeded for 48h, so chart windows longer than that fall through to the synthetic generator in mock_chart_history.go. That generator produced cpu, memory, disk and the I/O pairs but never memoryused, so a 7d workloads read returned 64 points for every other series and zero for memoryused. The memory column in host-capacity mode had no series to draw at all, which reads as a broken column rather than missing mock data. Real installs are unaffected: the live PVE tick writes memoryused to the metrics store and the store rollup groups by metric_type without an allowlist. The synthetic generator now derives memoryused from the sampled memory percentage and the fixture memory capacity, the same derivation live mock ticks and the seeder already use, so the series stays continuous across the seed boundary. Capacity comes from a new fixture registry beside the existing metric role registry rather than a per-call fixture graph clone. Docker containers and pods stay out of it, matching the Proxmox-only memoryused contract. --- .../v6/internal/subsystems/monitoring.md | 11 ++- internal/mock/fixture_graph.go | 8 +-- internal/mock/generator_test.go | 46 ++++++++++++ internal/mock/metric_personas.go | 55 ++++++++++++++- .../monitoring/canonical_guardrails_test.go | 70 +++++++++++++++++++ internal/monitoring/mock_chart_history.go | 38 +++++++++- 6 files changed, 221 insertions(+), 7 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 4121e47a5..50ea99e48 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -487,7 +487,16 @@ changes. metric writes. Proxmox guest memory history stores both the canonical guest-relative `memory` percentage and raw `memoryused` bytes so API consumers can apply alternate capacity denominators without reconstructing - bytes from a mutable guest allocation. + bytes from a mutable guest allocation. Mock mode owes the same pair on + every chart window. Seeded mock history covers a bounded window, so ranges + beyond it fall through to the synthetic generator in + `internal/monitoring/mock_chart_history.go`, and that generator must derive + `memoryused` from the sampled `memory` percentage and the fixture memory + capacity rather than omitting the series. Capacity resolves through the + fixture registries synced in `internal/mock/metric_personas.go`, never a + per-call fixture graph clone on the chart path. A percentage series without + its byte companion silently empties the host-capacity memory column instead + of degrading it. Discovery config and configured-host IP resolution must stay off the monitor lock. `internal/monitoring/monitor_discovery_helpers.go` exposes the canonical `discoveryConfigSnapshot()` that discovery providers consume, diff --git a/internal/mock/fixture_graph.go b/internal/mock/fixture_graph.go index 71fa01a53..0b968c37c 100644 --- a/internal/mock/fixture_graph.go +++ b/internal/mock/fixture_graph.go @@ -35,12 +35,12 @@ func buildFixtureGraph(cfg MockConfig, now time.Time) FixtureGraph { AvailabilityFixtures: defaultAvailabilityFixtures(now), } applyDemoScenarioGraph(&graph, now) - syncMetricRoleRegistryFromGraph(graph) + syncMetricFixtureRegistriesFromGraph(graph) graph.UpdateMetrics(cfg, now) graph.AlertHistory = buildAlertHistory(graph.State.Nodes, graph.State.VMs, graph.State.Containers) resources, _ := graph.UnifiedResourceSnapshot() graph.ActionFixtures = buildActionFixtures(resources, now) - syncMetricRoleRegistryFromGraph(graph) + syncMetricFixtureRegistriesFromGraph(graph) return graph } @@ -62,13 +62,13 @@ func (g *FixtureGraph) UpdateMetrics(cfg MockConfig, now time.Time) { setMockUpdateInterval(cfg.UpdateInterval) applyDemoScenarioGraph(g, now) - syncMetricRoleRegistryFromGraph(*g) + syncMetricFixtureRegistriesFromGraph(*g) updateFixtureStateMetricsAt(&g.State, cfg, now) g.PlatformFixtures = rebasePlatformFixtures(g.PlatformFixtures, now) g.AvailabilityFixtures = rebaseAvailabilityFixtures(g.AvailabilityFixtures, now) applyDemoScenarioGraph(g, now) g.DiscoveryFixtures = buildDiscoveryFixtures(g.State, now) - syncMetricRoleRegistryFromGraph(*g) + syncMetricFixtureRegistriesFromGraph(*g) } func (g *FixtureGraph) UpdateAlertSnapshots(active []alerts.Alert, resolved []models.ResolvedAlert) { diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go index d1a6aa61b..e60f3f401 100644 --- a/internal/mock/generator_test.go +++ b/internal/mock/generator_test.go @@ -802,3 +802,49 @@ func diffIdentitySets(first, second []string) string { } return out } + +// Host-relative guest memory history is a byte series derived from fixture +// capacity, so the fixture registries must expose that capacity per guest +// without a caller cloning the whole graph on every lookup. +func TestMemoryTotalForResourceTracksFixtureCapacity(t *testing.T) { + mustSetEnabled(t, true) + t.Cleanup(func() { + mustSetEnabled(t, false) + }) + + graph := CurrentFixtureGraph() + + checked := 0 + for _, vm := range graph.State.VMs { + if vm.ID == "" || vm.Memory.Total <= 0 { + continue + } + if got := MemoryTotalForResource("vm", vm.ID); got != float64(vm.Memory.Total) { + t.Fatalf("MemoryTotalForResource(vm, %s) = %f, want %d", vm.ID, got, vm.Memory.Total) + } + checked++ + if checked >= 3 { + break + } + } + if checked == 0 { + t.Fatal("fixture graph has no VM with a memory total") + } + + for _, ct := range graph.State.Containers { + if ct.ID == "" || ct.Memory.Total <= 0 { + continue + } + if got := MemoryTotalForResource("container", ct.ID); got != float64(ct.Memory.Total) { + t.Fatalf("MemoryTotalForResource(container, %s) = %f, want %d", ct.ID, got, ct.Memory.Total) + } + break + } + + if got := MemoryTotalForResource("vm", "definitely-not-a-fixture-guest"); got != 0 { + t.Fatalf("unknown guest capacity = %f, want 0", got) + } + if got := MemoryTotalForResource("vm", " "); got != 0 { + t.Fatalf("blank guest capacity = %f, want 0", got) + } +} diff --git a/internal/mock/metric_personas.go b/internal/mock/metric_personas.go index 621174a08..2105ee3e5 100644 --- a/internal/mock/metric_personas.go +++ b/internal/mock/metric_personas.go @@ -31,8 +31,14 @@ type weightedMetricRole struct { var metricRoleRegistry atomic.Value +// metricMemoryTotalRegistry holds fixture memory capacity in bytes per guest. +// Host-relative memory history is a byte series, so generators derive it from +// this capacity rather than sampling a second percentage. +var metricMemoryTotalRegistry atomic.Value + func init() { metricRoleRegistry.Store(map[string]string{}) + metricMemoryTotalRegistry.Store(map[string]float64{}) } func metricRoleRegistryKey(resourceClass, resourceID string) string { @@ -76,8 +82,55 @@ func MetricRole(resourceClass, resourceID string) string { return inferMetricRole(resourceClass, resourceID) } -func syncMetricRoleRegistryFromGraph(graph FixtureGraph) { +// MemoryTotalForResource returns the fixture memory capacity in bytes for a +// mock guest, or 0 when that resource has no known capacity. +func MemoryTotalForResource(resourceClass, resourceID string) float64 { + resourceID = strings.TrimSpace(resourceID) + if resourceID == "" { + return 0 + } + registry, _ := metricMemoryTotalRegistry.Load().(map[string]float64) + if registry == nil { + return 0 + } + return registry[metricRoleRegistryKey(resourceClass, resourceID)] +} + +func setMetricMemoryTotalRegistry(registry map[string]float64) { + cloned := make(map[string]float64, len(registry)) + for key, value := range registry { + key = strings.TrimSpace(key) + if key == "" || value <= 0 { + continue + } + cloned[key] = value + } + metricMemoryTotalRegistry.Store(cloned) +} + +func buildMetricMemoryTotalRegistry(graph FixtureGraph) map[string]float64 { + registry := make(map[string]float64) + record := func(resourceClass, resourceID string, total int64) { + resourceID = strings.TrimSpace(resourceID) + if resourceID == "" || total <= 0 { + return + } + registry[metricRoleRegistryKey(resourceClass, resourceID)] = float64(total) + } + + for _, vm := range graph.State.VMs { + record("vm", vm.ID, vm.Memory.Total) + } + for _, ct := range graph.State.Containers { + record("container", ct.ID, ct.Memory.Total) + } + + return registry +} + +func syncMetricFixtureRegistriesFromGraph(graph FixtureGraph) { setMetricRoleRegistry(buildMetricRoleRegistry(graph)) + setMetricMemoryTotalRegistry(buildMetricMemoryTotalRegistry(graph)) } func buildMetricRoleRegistry(graph FixtureGraph) map[string]string { diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index f62b9b9ee..9831fb5da 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -2743,3 +2743,73 @@ func TestResourceStaleThresholdsPreserveDefaultFloors(t *testing.T) { t.Fatalf("default PMG threshold = %v, want %v", got, 120*time.Second) } } + +// Mock history is seeded for a bounded window, so chart ranges longer than the +// seed fall through to the synthetic generator. That generator must still carry +// the host-relative `memoryused` byte series, or the workloads memory column in +// host-capacity mode has nothing to draw at 7d while every other series renders. +func TestMockGuestChartHistoryCarriesMemoryUsedBeyondSeededWindow(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + graph := mock.CurrentFixtureGraph() + var ( + guestID string + total float64 + ) + for _, vm := range graph.State.VMs { + if vm.ID != "" && vm.Memory.Total > 0 { + guestID = vm.ID + total = float64(vm.Memory.Total) + break + } + } + if guestID == "" { + t.Fatal("mock fixture graph has no VM with a memory total") + } + + series := mockGuestMetricsForChart("vm", guestID, 7*24*time.Hour) + memoryUsed := series["memoryused"] + memoryPercent := series["memory"] + + if len(memoryUsed) == 0 { + t.Fatalf("expected a memoryused series for %s over 7d, got none", guestID) + } + if len(memoryUsed) != len(memoryPercent) { + t.Fatalf( + "expected memoryused to track the memory series length, got %d vs %d", + len(memoryUsed), + len(memoryPercent), + ) + } + + for i, point := range memoryUsed { + if !point.Timestamp.Equal(memoryPercent[i].Timestamp) { + t.Fatalf("memoryused timestamp %d diverged from the memory series", i) + } + want := total * (math.Max(0, math.Min(100, memoryPercent[i].Value)) / 100) + if math.Abs(point.Value-want) > 1 { + t.Fatalf("memoryused[%d] = %f, want %f bytes", i, point.Value, want) + } + } +} + +// Docker containers and pods are outside the Proxmox host-capacity memory +// contract, so the synthetic generator must not start emitting bytes for them. +func TestMockGuestChartHistorySkipsMemoryUsedForNonProxmoxGuests(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + for _, resourceType := range []string{"dockerContainer", "k8s"} { + series := mockGuestMetricsForChart(resourceType, "any-guest-id", 7*24*time.Hour) + if len(series["memoryused"]) != 0 { + t.Fatalf( + "expected no memoryused series for %s, got %d points", + resourceType, + len(series["memoryused"]), + ) + } + } +} diff --git a/internal/monitoring/mock_chart_history.go b/internal/monitoring/mock_chart_history.go index bd9a932ba..586d24fae 100644 --- a/internal/monitoring/mock_chart_history.go +++ b/internal/monitoring/mock_chart_history.go @@ -4,6 +4,8 @@ import ( "math" "strings" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/mock" ) var ( @@ -75,13 +77,47 @@ func mockGuestMetricsForChart(resourceType, resourceID string, duration time.Dur } timestamps := mockChartTimestamps(duration) - result := make(map[string][]MetricPoint, len(mockGuestChartMetricTypes)) + result := make(map[string][]MetricPoint, len(mockGuestChartMetricTypes)+1) for _, metricType := range mockGuestChartMetricTypes { result[metricType] = mockCanonicalMetricSeries(resourceType, resourceID, metricType, timestamps) } + if series := mockGuestMemoryUsedSeries(resourceType, resourceID, result["memory"]); len(series) > 0 { + result["memoryused"] = series + } return result } +// mockGuestMemoryUsedSeries derives the host-relative memory byte series from +// the guest-relative percentage series and the fixture memory capacity, the +// same derivation live mock ticks and the history seeder use. Without it a +// chart window that falls outside the seeded history, such as 7d, returns no +// `memoryused` at all and the host-capacity memory column has nothing to draw. +func mockGuestMemoryUsedSeries(resourceType, resourceID string, memoryPercent []MetricPoint) []MetricPoint { + switch resourceType { + case "vm", "container": + default: + return nil + } + if len(memoryPercent) == 0 { + return nil + } + + total := mock.MemoryTotalForResource(resourceType, resourceID) + if total <= 0 { + return nil + } + + series := make([]MetricPoint, len(memoryPercent)) + for i, point := range memoryPercent { + percent := math.Max(0, math.Min(100, point.Value)) + series[i] = MetricPoint{ + Timestamp: point.Timestamp, + Value: total * (percent / 100), + } + } + return series +} + func mockNodeMetricsForChart(nodeID string, metricTypes []string, duration time.Duration) map[string][]MetricPoint { nodeID = strings.TrimSpace(nodeID) if nodeID == "" {