Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,19 @@ func (ms *MemorySource) VirtualMemoryStatWithContext(ctx context.Context) (*mem.

var usedMemoryPercent float64

usedMemory := memoryUsageInBytes - memoryStat.cached
usedMemory := saturatingSub(memoryUsageInBytes, memoryStat.cached)

if memoryLimitInBytes > 0 {
usedMemoryPercent = float64(100 * usedMemory / memoryLimitInBytes)
}

cgroupStat.Total = memoryLimitInBytes
cgroupStat.Available = memoryLimitInBytes - usedMemory
cgroupStat.Available = saturatingSub(memoryLimitInBytes, usedMemory)
cgroupStat.Used = usedMemory
cgroupStat.Cached = memoryStat.cached
cgroupStat.Shared = memoryStat.shared
cgroupStat.UsedPercent = usedMemoryPercent
cgroupStat.Free = memoryLimitInBytes - usedMemory
cgroupStat.Free = saturatingSub(memoryLimitInBytes, usedMemory)

@karensantana karensantana Jul 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment(non-blocking): I know this was not introduced in this PR, however it is interesting that Free and Available are holding the same value (and the metrics tests are passing). According to AI these values are not the same. 🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Free currently duplicates Available (limit - (usage - cache)). It should be computed from total usage (limit - usage) while Available can remain cache-adjusted. Existing tests pass because they validate current behavior; they should be updated alongside the fix.


return &cgroupStat, nil
}
Expand Down Expand Up @@ -181,3 +181,12 @@ func V1DefaultMaxValue() string {
maxInt := int64(math.MaxInt64)
return strconv.FormatInt((maxInt/pageSize)*pageSize, 10)
}

// saturatingSub subtracts b from a, clamping to 0 on underflow.
func saturatingSub(a, b uint64) uint64 {
if a < b {
return 0
}

return a - b
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,90 @@ func TestVirtualMemoryStat(t *testing.T) {
})
}
}

func TestSaturatingSub(t *testing.T) {
tests := []struct {
name string
a, b uint64
expected uint64
}{
{
name: "normal subtraction",
a: 100,
b: 40,
expected: 60,
},
{
name: "equal values return zero",
a: 100,
b: 100,
expected: 0,
},
{
name: "b greater than a clamps to zero (cached exceeds usage)",
a: 100,
b: 200,
expected: 0,
},
{
name: "b greater than a clamps to zero (used exceeds limit)",
a: 400,
b: 500,
expected: 0,
},
{
name: "zero minus zero returns zero",
a: 0,
b: 0,
expected: 0,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, saturatingSub(test.a, test.b))
})
}
}

func TestVirtualMemoryStat_CachedExceedsUsage(t *testing.T) {
dir := t.TempDir()

// cgroup.controllers marks basePath as v2
require.NoError(t, os.WriteFile(path.Join(dir, "cgroup.controllers"), []byte(""), 0o600))
require.NoError(t, os.WriteFile(path.Join(dir, "memory.max"), []byte("1000\n"), 0o600))
require.NoError(t, os.WriteFile(path.Join(dir, "memory.current"), []byte("100\n"), 0o600))
require.NoError(t, os.WriteFile(path.Join(dir, "memory.stat"), []byte("file 200\nshmem 0\n"), 0o600))

src := NewMemorySource(dir)
stat, err := src.VirtualMemoryStatWithContext(t.Context())
require.NoError(t, err)

// usedMemory = saturatingSub(100, 200) = 0 — must not wrap to 2^64
assert.Equal(t, uint64(0), stat.Used, "Used must clamp to 0 when cached > usage")
// Free = saturatingSub(1000, 0) = 1000 — no secondary underflow
assert.Equal(t, uint64(1000), stat.Free, "Free must equal limit when usedMemory is 0")
assert.InDelta(t, float64(0), stat.UsedPercent, 0.001, "UsedPercent must be 0")
}

func TestVirtualMemoryStat_UsedExceedsLimit(t *testing.T) {
dir := t.TempDir()

// cgroup.controllers marks basePath as v2
require.NoError(t, os.WriteFile(path.Join(dir, "cgroup.controllers"), []byte(""), 0o600))
// usage (500) > limit (400) — OOM/transient cgroup state
require.NoError(t, os.WriteFile(path.Join(dir, "memory.max"), []byte("400\n"), 0o600))
require.NoError(t, os.WriteFile(path.Join(dir, "memory.current"), []byte("500\n"), 0o600))
require.NoError(t, os.WriteFile(path.Join(dir, "memory.stat"), []byte("file 0\nshmem 0\n"), 0o600))

src := NewMemorySource(dir)
stat, err := src.VirtualMemoryStatWithContext(t.Context())
require.NoError(t, err)

// usedMemory = saturatingSub(500, 0) = 500 — no underflow
assert.Equal(t, uint64(500), stat.Used, "Used = usage - cached = 500")
// Free = saturatingSub(400, 500) = 0 — must not wrap to 2^64
assert.Equal(t, uint64(0), stat.Free, "Free must clamp to 0 when used > limit")
// Available = saturatingSub(400, 500) = 0 — same expression
assert.Equal(t, uint64(0), stat.Available, "Available must clamp to 0 when used > limit")
}
Loading