fix(monitoring): use MemAvailable instead of MemTotal-MemFree for Linux guest/node memory

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
This commit is contained in:
rcourtman 2026-06-27 17:41:00 +01:00
parent 22ed97f562
commit b5ddbd5606
4 changed files with 114 additions and 1 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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 {