From b5ddbd5606a99010e3f0ed8a23fffc82fb184b60 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 27 Jun 2026 17:41:00 +0100 Subject: [PATCH] fix(monitoring): use MemAvailable instead of MemTotal-MemFree for Linux guest/node memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Proxmox API's meminfo/status payload omits Available, Buffers, and Cached (common for QEMU guest-agent and node status responses), the code derived 'available' from Free alone — producing used = Total-Free which counts reclaimable page cache as used memory (e.g. 94% instead of the correct 76%). Guest path (deriveGuestMemInfoAvailable): return 0 when cache metrics are completely missing and only Free is available, so resolveGuestStatusMemory tries better sources: guest-agent /proc/meminfo file-read (returns MemAvailable), RRD memavailable, or the linked host agent. Node path (resolveNodeMemory): always try RRD memavailable when cache metrics are missing, not only when effectiveAvailable == 0 — previously a non-zero Free value blocked the RRD fallback. Refs #1501 --- internal/monitoring/guest_memory_sources.go | 3 + .../monitoring/guest_memory_sources_test.go | 94 +++++++++++++++++++ internal/monitoring/node_memory_sources.go | 2 +- .../monitoring/node_memory_sources_test.go | 16 ++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/internal/monitoring/guest_memory_sources.go b/internal/monitoring/guest_memory_sources.go index 2a6175386..a89f31a8c 100644 --- a/internal/monitoring/guest_memory_sources.go +++ b/internal/monitoring/guest_memory_sources.go @@ -73,6 +73,9 @@ func deriveGuestMemInfoAvailable(memInfo *proxmox.VMMemInfo, guestRaw *VMMemoryR } } } + if missingCacheMetrics { + return 0, "" + } return componentAvailable, "derived-free-buffers-cached" default: if availableFromUsed > 0 && missingCacheMetrics { diff --git a/internal/monitoring/guest_memory_sources_test.go b/internal/monitoring/guest_memory_sources_test.go index 9a78a1c6a..80ee1b314 100644 --- a/internal/monitoring/guest_memory_sources_test.go +++ b/internal/monitoring/guest_memory_sources_test.go @@ -6,6 +6,100 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) +func TestDeriveGuestMemInfoAvailable(t *testing.T) { + kb := uint64(1024) + miB := uint64(1024 * 1024) + giB := uint64(1024 * 1024 * 1024) + + tests := []struct { + name string + memInfo *proxmox.VMMemInfo + wantAvailable uint64 + wantSource string + }{ + { + name: "uses available field when present", + memInfo: &proxmox.VMMemInfo{ + Total: 8 * giB, + Used: 6 * giB, + Free: 2 * giB, + Available: 4 * giB, + Buffers: 500 * miB, + Cached: 1 * giB, + }, + wantAvailable: 4 * giB, + wantSource: "available-field", + }, + { + name: "derives from free+buffers+cached when available missing", + memInfo: &proxmox.VMMemInfo{ + Total: 8 * giB, + Used: 5 * giB, + Free: 2 * giB, + Buffers: 500 * miB, + Cached: 1 * giB, + }, + wantAvailable: 2*giB + 500*miB + 1*giB, + wantSource: "derived-free-buffers-cached", + }, + { + name: "returns zero when only free is available (no cache metrics)", + memInfo: &proxmox.VMMemInfo{ + Total: 7796964 * kb, + Used: 7352140 * kb, + Free: 444824 * kb, + }, + wantAvailable: 0, + wantSource: "", + }, + { + name: "returns availableFromUsed when used excludes cache despite missing cache metrics", + memInfo: &proxmox.VMMemInfo{ + Total: 8 * giB, + Used: 3 * giB, + Free: 1 * giB, + }, + wantAvailable: 5 * giB, + wantSource: "derived-total-minus-used", + }, + { + name: "returns zero when only total and used with used equal to total minus free", + memInfo: &proxmox.VMMemInfo{ + Total: 8 * giB, + Used: 7 * giB, + }, + wantAvailable: 1 * giB, + wantSource: "derived-total-minus-used", + }, + { + name: "returns zero for empty meminfo", + memInfo: &proxmox.VMMemInfo{ + Total: 8 * giB, + }, + wantAvailable: 0, + wantSource: "", + }, + { + name: "nil meminfo returns zero", + memInfo: nil, + wantAvailable: 0, + wantSource: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAvailable, gotSource := deriveGuestMemInfoAvailable(tt.memInfo, nil) + if gotAvailable != tt.wantAvailable { + t.Fatalf("available = %d, want %d", gotAvailable, tt.wantAvailable) + } + if gotSource != tt.wantSource { + t.Fatalf("source = %q, want %q", gotSource, tt.wantSource) + } + }) + } +} + func TestSelectGuestLowTrustUsedMemory(t *testing.T) { giB := uint64(1024 * 1024 * 1024) diff --git a/internal/monitoring/node_memory_sources.go b/internal/monitoring/node_memory_sources.go index ed79e8f08..a6f5c1a18 100644 --- a/internal/monitoring/node_memory_sources.go +++ b/internal/monitoring/node_memory_sources.go @@ -69,7 +69,7 @@ func (m *Monitor) resolveNodeMemory( usedRRDAvailableFallback := false rrdMemUsedFallback := false - if effectiveAvailable == 0 && missingCacheMetrics { + if missingCacheMetrics { if metrics, err := m.getNodeRRDMetrics(ctx, client, nodeName); err == nil { haveRRDMetrics = true rrdMetrics = metrics diff --git a/internal/monitoring/node_memory_sources_test.go b/internal/monitoring/node_memory_sources_test.go index d36d01d83..05e5c9236 100644 --- a/internal/monitoring/node_memory_sources_test.go +++ b/internal/monitoring/node_memory_sources_test.go @@ -64,6 +64,22 @@ func TestResolveNodeMemoryCharacterization(t *testing.T) { wantUsed: 6 * gib, wantRawSource: "rrd-memused", }, + { + name: "node status has only free so rrd memavailable is preferred over total-minus-free", + memory: &proxmox.MemoryStatus{ + Total: 8 * gib, + Used: 7 * gib, + Free: 1 * gib, + }, + rrdPoints: []proxmox.NodeRRDPoint{{ + MemTotal: floatPtr(float64(8 * gib)), + MemAvailable: floatPtr(float64(4 * gib)), + }}, + wantSource: "rrd-memavailable", + wantFallback: "rrd-memavailable", + wantUsed: 4 * gib, + wantRawSource: "rrd-memavailable", + }, } for _, tt := range tests {