mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
memory(cache): extend the reclaimable split to standalone host agents
f62f35e24restored the v5 used | cache | free memory split for Proxmox nodes and guests, but standalone host agents still reported a flat used/free pair, so the Machines page memory bar could not show the reclaimable segment. Flagged by the Machines page v5 parity audit. - Host agent reports cacheBytes (gopsutil Available minus Free); the ZFS ARC adjustment recomputes free so used + cache + free still covers the total. - ApplyHostReport maps the field into models.Memory.Cache and clamps inconsistent or older-agent reports so used + cache never exceeds total. - AgentMemoryMeta carries cache onto unified resources so the frontend agent payload exposes it. - Mock generic hosts split a third of non-used pages as cache, and the node-linked host conversion now holds the invariant instead of stacking the node's cache on top of a recomputed free. - Contracts: monitoring, unified-resources, and storage-recovery now document the split (also covering thef62f35e24node/guest surface, which landed without contract deltas).
This commit is contained in:
@@ -464,6 +464,14 @@ counters without requiring a parallel SMART row. Monitoring may normalize legacy
|
||||
statuses and filter empty slots, but it must not collapse assigned Unraid
|
||||
array/cache members back to generic host disks or discard native fields before
|
||||
unified resources builds storage and physical-disk resources.
|
||||
Host-agent memory ingest carries the reclaimable page-cache split. The host
|
||||
agent reports `cacheBytes` (gopsutil Available minus Free, with the ZFS ARC
|
||||
adjustment recomputing free so used + cache + free still covers the total),
|
||||
and `internal/monitoring/monitor_agents.go` maps it into
|
||||
`models.Memory.Cache`, clamping inconsistent or older-agent reports so
|
||||
used + cache never exceeds total. Mock fixtures author the same split for
|
||||
generic hosts and node-linked host agents, and any mock drift updater must
|
||||
hold the used + cache + free invariant as sampled usage changes.
|
||||
VMware vSphere now also has a locked phase-1 ingestion boundary under this
|
||||
lane. The admitted direction is vCenter-only in phase 1, and monitoring must
|
||||
stay API-first through the
|
||||
|
||||
@@ -965,6 +965,14 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
forking the toolbar. The source scope flows through
|
||||
`forcedSourceFilter` as a typed page input; the source filter remains
|
||||
available in the toolbar only when not forced.
|
||||
41. Keep agent memory composition descriptive on the shared unified-resource
|
||||
contract. `internal/unifiedresources/types.go` carries the reclaimable
|
||||
page-cache split (`AgentMemoryMeta.cache`, holding used + cache + free
|
||||
within the reported total) as host RAM description for machine surfaces.
|
||||
Storage and recovery must not reinterpret that reclaimable RAM figure as
|
||||
disk cache, ZFS ARC sizing, storage-tier health, or capacity-planning
|
||||
evidence; disk and pool truth stays on the canonical storage and
|
||||
physical-disk resources.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
||||
@@ -1262,6 +1262,13 @@ presentation and host/appliance support-floor copy. Raw appliance identity
|
||||
aliases such as `unraid-os` may be accepted only through the generated
|
||||
host-profile token projection and must resolve to a governed profile id before
|
||||
they reach platform filters, source IDs, or top-level resource identity.
|
||||
Unified-resource `AgentData` also carries the host memory composition:
|
||||
`AgentMemoryMeta` exposes the reclaimable page-cache split (`cache`) beside
|
||||
used/free/swap so machine surfaces can render used | cache | free without a
|
||||
parallel payload, mirroring the `proxmox.memoryCache` transport nodes and
|
||||
guests use. The field is additive and omitted when an agent does not report
|
||||
it; consumers must treat missing cache as zero rather than inferring it from
|
||||
free space.
|
||||
Frontend resource identity presenters may append a runtime version to a
|
||||
displayed system badge only when that version is sourced from the same canonical
|
||||
platform or host-profile identity, such as PVE `ResourceProxmoxMeta.pveVersion`
|
||||
|
||||
@@ -77,6 +77,14 @@ func Collect(ctx context.Context, diskExclude []string) (Snapshot, error) {
|
||||
freeBytes := memStats.Free
|
||||
usedPercent := memStats.UsedPercent
|
||||
|
||||
// Reclaimable page cache: Available counts the pages the kernel would hand
|
||||
// back under pressure on top of truly-free ones, so the gap is buff/cache.
|
||||
// Reported separately so the memory bar can show used | cache | free.
|
||||
cacheBytes := uint64(0)
|
||||
if memStats.Available > memStats.Free {
|
||||
cacheBytes = memStats.Available - memStats.Free
|
||||
}
|
||||
|
||||
// ZFS ARC memory is reclaimable under pressure but is counted as "used" by
|
||||
// both FreeBSD (wired memory) and Linux (not in MemAvailable, openzfs/zfs#10255).
|
||||
// Subtract it from Used to reflect actual memory pressure. Refs: #1264/#1051
|
||||
@@ -95,8 +103,13 @@ func Collect(ctx context.Context, diskExclude []string) (Snapshot, error) {
|
||||
usedPercent = 100
|
||||
}
|
||||
}
|
||||
if memStats.Total >= usedBytes {
|
||||
freeBytes = memStats.Total - usedBytes
|
||||
// Recompute free so used + cache + free still covers the total after
|
||||
// the ARC pages move out of used.
|
||||
if memStats.Total >= usedBytes+cacheBytes {
|
||||
freeBytes = memStats.Total - usedBytes - cacheBytes
|
||||
} else if memStats.Total >= usedBytes {
|
||||
cacheBytes = memStats.Total - usedBytes
|
||||
freeBytes = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +122,7 @@ func Collect(ctx context.Context, diskExclude []string) (Snapshot, error) {
|
||||
TotalBytes: int64(memStats.Total),
|
||||
UsedBytes: int64(usedBytes),
|
||||
FreeBytes: int64(freeBytes),
|
||||
CacheBytes: int64(cacheBytes),
|
||||
Usage: usedPercent,
|
||||
SwapTotal: int64(memStats.SwapTotal),
|
||||
SwapUsed: swapUsed,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
godisk "github.com/shirou/gopsutil/v4/disk"
|
||||
gomem "github.com/shirou/gopsutil/v4/mem"
|
||||
)
|
||||
|
||||
func TestCollectDiskIO(t *testing.T) {
|
||||
@@ -111,3 +112,39 @@ func TestCollectDisks_DeviceDeduplication(t *testing.T) {
|
||||
t.Errorf("Total storage should be %d bytes, got %d bytes", expectedTotal, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSplitsReclaimableCache(t *testing.T) {
|
||||
origVirtualMemory := virtualMemory
|
||||
t.Cleanup(func() { virtualMemory = origVirtualMemory })
|
||||
|
||||
const gib = uint64(1024 * 1024 * 1024)
|
||||
virtualMemory = func(ctx context.Context) (*gomem.VirtualMemoryStat, error) {
|
||||
return &gomem.VirtualMemoryStat{
|
||||
Total: 16 * gib,
|
||||
Used: 6 * gib,
|
||||
Free: 4 * gib,
|
||||
// Available - Free = 6 GiB of reclaimable buff/cache.
|
||||
Available: 10 * gib,
|
||||
UsedPercent: 37.5,
|
||||
}, nil
|
||||
}
|
||||
|
||||
snapshot, err := Collect(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Collect failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := snapshot.Memory.CacheBytes, int64(6*gib); got != want {
|
||||
t.Fatalf("CacheBytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := snapshot.Memory.UsedBytes, int64(6*gib); got != want {
|
||||
t.Fatalf("UsedBytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := snapshot.Memory.FreeBytes, int64(4*gib); got != want {
|
||||
t.Fatalf("FreeBytes = %d, want %d", got, want)
|
||||
}
|
||||
sum := snapshot.Memory.UsedBytes + snapshot.Memory.CacheBytes + snapshot.Memory.FreeBytes
|
||||
if sum > snapshot.Memory.TotalBytes {
|
||||
t.Fatalf("used+cache+free = %d exceeds total %d", sum, snapshot.Memory.TotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2889,7 +2889,10 @@ func generateHosts(config MockConfig) []models.Host {
|
||||
memTotal := int64(memTotalGiB) << 30
|
||||
memUsage := SampleMetric("agent", hostID, "memory", now)
|
||||
memUsed := int64(float64(memTotal) * (memUsage / 100.0))
|
||||
memFree := memTotal - memUsed
|
||||
// Roughly a third of the non-used pages read as reclaimable buff/cache
|
||||
// so the machine memory split is exercisable in mock mode.
|
||||
memCache := (memTotal - memUsed) / 3
|
||||
memFree := memTotal - memUsed - memCache
|
||||
|
||||
swapTotal := int64(rand.Intn(32)) << 30
|
||||
swapUsed := int64(float64(swapTotal) * rand.Float64())
|
||||
@@ -3051,7 +3054,7 @@ func generateHosts(config MockConfig) []models.Host {
|
||||
CPUCount: cpuCount,
|
||||
CPUUsage: cpuUsage,
|
||||
LoadAverage: loadAverage,
|
||||
Memory: models.Memory{Total: memTotal, Used: memUsed, Free: memFree, Usage: memUsage, SwapTotal: swapTotal, SwapUsed: swapUsed},
|
||||
Memory: models.Memory{Total: memTotal, Used: memUsed, Free: memFree, Cache: memCache, Usage: memUsage, SwapTotal: swapTotal, SwapUsed: swapUsed},
|
||||
Disks: disks,
|
||||
NetworkInterfaces: network,
|
||||
Sensors: sensors,
|
||||
@@ -3489,6 +3492,12 @@ func buildMockLinkedHostFromNode(node models.Node, hostID string, hostIndex int,
|
||||
if memory.Free < 0 {
|
||||
memory.Free = 0
|
||||
}
|
||||
// The node memory carries a reclaimable-cache split; keep the
|
||||
// used + cache + free invariant after recomputing free.
|
||||
if memory.Cache > memory.Free {
|
||||
memory.Cache = memory.Free
|
||||
}
|
||||
memory.Free -= memory.Cache
|
||||
|
||||
// Add swap for PVE node hosts
|
||||
memory.SwapTotal = int64(8+rand.Intn(24)) << 30
|
||||
|
||||
@@ -140,6 +140,14 @@ func TestBuildFixtureStateIncludesHostAgents(t *testing.T) {
|
||||
if host.Status == "" {
|
||||
t.Fatalf("host agent missing status: %+v", host)
|
||||
}
|
||||
if host.Memory.Total > 0 {
|
||||
if host.Memory.Cache <= 0 {
|
||||
t.Fatalf("host agent %s should report reclaimable cache so the memory split is exercisable: %+v", host.ID, host.Memory)
|
||||
}
|
||||
if sum := host.Memory.Used + host.Memory.Cache + host.Memory.Free; sum > host.Memory.Total {
|
||||
t.Fatalf("host agent %s memory used+cache+free %d exceeds total %d", host.ID, sum, host.Memory.Total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1759,10 +1759,18 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
||||
Total: report.Metrics.Memory.TotalBytes,
|
||||
Used: report.Metrics.Memory.UsedBytes,
|
||||
Free: report.Metrics.Memory.FreeBytes,
|
||||
Cache: report.Metrics.Memory.CacheBytes,
|
||||
Usage: safeFloat(report.Metrics.Memory.Usage),
|
||||
SwapTotal: report.Metrics.Memory.SwapTotal,
|
||||
SwapUsed: report.Metrics.Memory.SwapUsed,
|
||||
}
|
||||
// Older agents don't report cache; clamp so used + cache never exceeds total.
|
||||
if memory.Cache < 0 {
|
||||
memory.Cache = 0
|
||||
}
|
||||
if memory.Total > 0 && memory.Used+memory.Cache > memory.Total {
|
||||
memory.Cache = max(0, memory.Total-memory.Used)
|
||||
}
|
||||
|
||||
// Fallback for LXC environments: gopsutil may read Total and Free correctly
|
||||
// from cgroup limits but return 0 for Used. Calculate Used from Total - Free.
|
||||
|
||||
@@ -2604,3 +2604,52 @@ func TestApplyHostReportMergesAgentCephWithProxmoxAPICluster(t *testing.T) {
|
||||
}
|
||||
t.Fatalf("expected canonical API Ceph pool alert using legacy agent override, got active alerts: %#v", active)
|
||||
}
|
||||
|
||||
func TestApplyHostReportMapsReclaimableMemoryCache(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
hostTokenBindings: make(map[string]string),
|
||||
config: &config.Config{},
|
||||
rateTracker: NewRateTracker(),
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
report := agentshost.Report{
|
||||
Agent: agentshost.AgentInfo{ID: "agent-cache", Version: "1.0.0", IntervalSeconds: 30},
|
||||
Host: agentshost.HostInfo{
|
||||
ID: "machine-cache",
|
||||
Hostname: "cache-host",
|
||||
Platform: "linux",
|
||||
},
|
||||
Timestamp: time.Now().UTC(),
|
||||
Metrics: agentshost.Metrics{
|
||||
Memory: agentshost.MemoryMetric{
|
||||
TotalBytes: 16 << 30,
|
||||
UsedBytes: 6 << 30,
|
||||
FreeBytes: 4 << 30,
|
||||
CacheBytes: 6 << 30,
|
||||
Usage: 37.5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
host, err := monitor.ApplyHostReport(report, &config.APITokenRecord{ID: "token-cache", Name: "Token"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyHostReport: %v", err)
|
||||
}
|
||||
if got, want := host.Memory.Cache, int64(6<<30); got != want {
|
||||
t.Fatalf("memory cache = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
// An inconsistent report (cache pushing past total) is clamped so the
|
||||
// used | cache | free split can never exceed the bar.
|
||||
report.Metrics.Memory.CacheBytes = 12 << 30
|
||||
host, err = monitor.ApplyHostReport(report, &config.APITokenRecord{ID: "token-cache", Name: "Token"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyHostReport clamp: %v", err)
|
||||
}
|
||||
if got, want := host.Memory.Cache, int64(10<<30); got != want {
|
||||
t.Fatalf("clamped memory cache = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) {
|
||||
Total: host.Memory.Total,
|
||||
Used: host.Memory.Used,
|
||||
Free: host.Memory.Free,
|
||||
Cache: host.Memory.Cache,
|
||||
SwapUsed: host.Memory.SwapUsed,
|
||||
SwapTotal: host.Memory.SwapTotal,
|
||||
},
|
||||
|
||||
@@ -1014,3 +1014,30 @@ func TestResourceFromDockerContainerUsesHostSighting(t *testing.T) {
|
||||
t.Fatalf("resource.LastSeen = %s, want zero when the host has never reported", resource.LastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceFromHostCarriesReclaimableMemoryCache(t *testing.T) {
|
||||
host := models.Host{
|
||||
ID: "cache-host",
|
||||
Hostname: "cache-host",
|
||||
Platform: "linux",
|
||||
Status: "online",
|
||||
Memory: models.Memory{
|
||||
Total: 16 << 30,
|
||||
Used: 6 << 30,
|
||||
Free: 4 << 30,
|
||||
Cache: 6 << 30,
|
||||
Usage: 37.5,
|
||||
},
|
||||
}
|
||||
|
||||
resource, _ := resourceFromHost(host)
|
||||
if resource.Agent == nil || resource.Agent.Memory == nil {
|
||||
t.Fatal("expected agent memory payload")
|
||||
}
|
||||
if got, want := resource.Agent.Memory.Cache, int64(6<<30); got != want {
|
||||
t.Fatalf("agent memory cache = %d, want %d", got, want)
|
||||
}
|
||||
if sum := resource.Agent.Memory.Used + resource.Agent.Memory.Cache + resource.Agent.Memory.Free; sum > resource.Agent.Memory.Total {
|
||||
t.Fatalf("agent memory used+cache+free %d exceeds total %d", sum, resource.Agent.Memory.Total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
@@ -400,3 +402,22 @@ func TestIsUnsupportedLegacyResourceIDAlias(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentMemoryMetaSerializesReclaimableCache(t *testing.T) {
|
||||
meta := AgentMemoryMeta{Total: 16, Used: 6, Free: 4, Cache: 6}
|
||||
payload, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal AgentMemoryMeta: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(payload), `"cache":6`) {
|
||||
t.Fatalf("expected cache in agent memory payload, got %s", payload)
|
||||
}
|
||||
|
||||
empty, err := json.Marshal(AgentMemoryMeta{Total: 16, Used: 6, Free: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal AgentMemoryMeta without cache: %v", err)
|
||||
}
|
||||
if strings.Contains(string(empty), "cache") {
|
||||
t.Fatalf("cache should be omitted when unreported, got %s", empty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,9 +738,11 @@ type HostCephMeta struct {
|
||||
|
||||
// AgentMemoryMeta describes agent-reported memory including swap.
|
||||
type AgentMemoryMeta struct {
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Used int64 `json:"used,omitempty"`
|
||||
Free int64 `json:"free,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Used int64 `json:"used,omitempty"`
|
||||
Free int64 `json:"free,omitempty"`
|
||||
// Cache is the reclaimable page cache; used + cache + free ≈ total.
|
||||
Cache int64 `json:"cache,omitempty"`
|
||||
SwapUsed int64 `json:"swapUsed,omitempty"`
|
||||
SwapTotal int64 `json:"swapTotal,omitempty"`
|
||||
}
|
||||
|
||||
@@ -67,9 +67,12 @@ type Metrics struct {
|
||||
|
||||
// MemoryMetric captures memory usage statistics in bytes.
|
||||
type MemoryMetric struct {
|
||||
TotalBytes int64 `json:"totalBytes,omitempty"`
|
||||
UsedBytes int64 `json:"usedBytes,omitempty"`
|
||||
FreeBytes int64 `json:"freeBytes,omitempty"`
|
||||
TotalBytes int64 `json:"totalBytes,omitempty"`
|
||||
UsedBytes int64 `json:"usedBytes,omitempty"`
|
||||
FreeBytes int64 `json:"freeBytes,omitempty"`
|
||||
// CacheBytes is the reclaimable page cache (Available - Free);
|
||||
// used + cache + free ≈ total.
|
||||
CacheBytes int64 `json:"cacheBytes,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
SwapTotal int64 `json:"swapTotalBytes,omitempty"`
|
||||
SwapUsed int64 `json:"swapUsedBytes,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user