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.
This commit is contained in:
rcourtman
2026-08-06 00:17:52 +01:00
parent a4478ce673
commit 1b8bb4e91c
6 changed files with 221 additions and 7 deletions
@@ -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,
+4 -4
View File
@@ -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) {
+46
View File
@@ -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)
}
}
+54 -1
View File
@@ -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 {
@@ -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"]),
)
}
}
}
+37 -1
View File
@@ -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 == "" {