Skip to content

Commit 98b0c56

Browse files
Consistent host memory (free and available).
1 parent 928e00f commit 98b0c56

4 files changed

Lines changed: 112 additions & 19 deletions

File tree

lib/process/metrics/host/memory.rb

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,37 @@ module Metrics
88
# Per-host (system-wide) memory metrics. Use Host::Memory for total/used/free and swap; use Process::Metrics::Memory for per-process metrics.
99
module Host
1010
# Struct for host memory snapshot. All sizes in bytes.
11+
# Stored: total_size, used_size, swap_*, reclaimable_size. free_size and available_size are derived.
1112
# @attribute total_size [Integer] Total memory (cgroup limit when in a container, else physical RAM).
12-
# @attribute used_size [Integer] Memory in use (total_size - free_size).
13-
# @attribute free_size [Integer] Available memory (MemAvailable-style: free + reclaimable).
13+
# @attribute used_size [Integer] Memory in use (kernel/cgroup view; on Linux includes reclaimable e.g. page cache).
1414
# @attribute swap_total_size [Integer, nil] Total swap, or nil if not available.
1515
# @attribute swap_used_size [Integer, nil] Swap in use, or nil if not available.
16-
Memory = Struct.new(:total_size, :used_size, :free_size, :swap_total_size, :swap_used_size) do
16+
# @attribute reclaimable_size [Integer, nil] Reclaimable memory (e.g. page cache, slab), or nil. Included in used_size.
17+
Memory = Struct.new(:total_size, :used_size, :swap_total_size, :swap_used_size, :reclaimable_size) do
18+
def to_h
19+
super.merge(free_size: free_size, available_size: available_size)
20+
end
21+
1722
alias as_json to_h
1823

1924
def to_json(*arguments)
2025
as_json.to_json(*arguments)
2126
end
2227

28+
# Complement of used: total_size - used_size. Same meaning on all platforms.
29+
def free_size
30+
total_size - used_size
31+
end
32+
33+
# Memory that could be used: free_size plus reclaimable. Use this for "available" when reclaimable_size is set; otherwise equals free_size.
34+
def available_size
35+
free_size + (reclaimable_size || 0)
36+
end
37+
2338
# Create a zero-initialized Host::Memory instance.
2439
# @returns [Memory]
2540
def self.zero
26-
self.new(0, 0, 0, nil, nil)
41+
self.new(0, 0, nil, nil, nil)
2742
end
2843

2944
# Whether host memory capture is supported on this platform.

lib/process/metrics/host/memory/darwin.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def self.capture
3838
used = [total - free, 0].max
3939
swap_total, swap_used = capture_swap
4040

41-
return Host::Memory.new(total, used, free, swap_total, swap_used)
41+
return Host::Memory.new(total, used, swap_total, swap_used, nil)
4242
end
4343

4444
# Total physical RAM in bytes, from sysctl hw.memsize.

lib/process/metrics/host/memory/linux.rb

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ class Memory::Linux
1515
# Any value >= 2^60 (1 exabyte) is treated as unlimited and we fall back to /proc/meminfo.
1616
# Reference: https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt
1717
CGROUP_V1_UNLIMITED_THRESHOLD = 2**60
18+
DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
1819

19-
def initialize
20+
def initialize(cgroup_root: nil)
21+
@cgroup_root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
2022
@meminfo = false
2123
end
2224

23-
# Capture current host memory. Reads total and used (from cgroup or meminfo), computes free, and parses swap from meminfo.
25+
# Capture current host memory. Reads total and used (from cgroup or meminfo), computes free, parses swap and reclaimable from meminfo/cgroup.
2426
# @returns [Host::Memory | Nil]
2527
def capture
2628
total = capture_total
@@ -29,15 +31,25 @@ def capture
2931
used = capture_used(total)
3032
used = 0 if used.nil? || used.negative?
3133
used = [used, total].min
32-
free = total - used
3334

3435
swap_total, swap_used = capture_swap
36+
reclaimable = capture_reclaimable
3537

36-
return Host::Memory.new(total, used, free, swap_total, swap_used)
38+
return Host::Memory.new(total, used, swap_total, swap_used, reclaimable)
3739
end
3840

3941
private
4042

43+
# Path for cgroups v2 (unified): e.g. /sys/fs/cgroup/memory.stat
44+
def cgroup_v2_path(name)
45+
"#{@cgroup_root}/#{name}"
46+
end
47+
48+
# Path for cgroups v1 (memory controller): e.g. /sys/fs/cgroup/memory/memory.stat
49+
def cgroup_v1_path(name)
50+
"#{@cgroup_root}/memory/#{name}"
51+
end
52+
4153
# Memoized /proc/meminfo contents. Used for total (MemTotal), used (via MemAvailable), and swap when not in a cgroup.
4254
# @returns [String | Nil]
4355
def meminfo
@@ -51,13 +63,13 @@ def meminfo
5163
# Total memory in bytes: cgroups v2 memory.max, cgroups v1 memory.limit_in_bytes (if < threshold), else MemTotal from meminfo.
5264
# @returns [Integer | Nil]
5365
def capture_total
54-
if File.exist?("/sys/fs/cgroup/memory.max")
55-
limit = File.read("/sys/fs/cgroup/memory.max").strip
66+
if File.exist?(cgroup_v2_path("memory.max"))
67+
limit = File.read(cgroup_v2_path("memory.max")).strip
5668
return limit.to_i if limit != "max"
5769
end
5870

59-
if File.exist?("/sys/fs/cgroup/memory/memory.limit_in_bytes")
60-
limit = File.read("/sys/fs/cgroup/memory/memory.limit_in_bytes").strip.to_i
71+
if File.exist?(cgroup_v1_path("memory.limit_in_bytes"))
72+
limit = File.read(cgroup_v1_path("memory.limit_in_bytes")).strip.to_i
6173
return limit if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
6274
end
6375

@@ -78,15 +90,15 @@ def capture_total
7890
# @parameter total [Integer] Total memory (used to compute used from MemAvailable when not in cgroup).
7991
# @returns [Integer | Nil]
8092
def capture_used(total)
81-
if File.exist?("/sys/fs/cgroup/memory.current")
82-
current = File.read("/sys/fs/cgroup/memory.current").strip.to_i
93+
if File.exist?(cgroup_v2_path("memory.current"))
94+
current = File.read(cgroup_v2_path("memory.current")).strip.to_i
8395
return current
8496
end
8597

86-
if File.exist?("/sys/fs/cgroup/memory/memory.usage_in_bytes")
87-
limit = File.read("/sys/fs/cgroup/memory/memory.limit_in_bytes").strip.to_i
98+
if File.exist?(cgroup_v1_path("memory.usage_in_bytes"))
99+
limit = File.read(cgroup_v1_path("memory.limit_in_bytes")).strip.to_i
88100
if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
89-
return File.read("/sys/fs/cgroup/memory/memory.usage_in_bytes").strip.to_i
101+
return File.read(cgroup_v1_path("memory.usage_in_bytes")).strip.to_i
90102
end
91103
end
92104

@@ -115,6 +127,37 @@ def capture_swap
115127

116128
return swap_total_bytes, swap_used_bytes
117129
end
130+
131+
# Reclaimable memory in bytes (page cache, buffers, reclaimable slab). Included in used_size.
132+
# From cgroups v2 memory.stat "file", cgroups v1 memory.stat "cache", or meminfo Cached + Buffers + SReclaimable.
133+
# @returns [Integer | Nil]
134+
def capture_reclaimable
135+
if File.exist?(cgroup_v2_path("memory.stat"))
136+
# cgroups v2: "file" is file-backed (page cache), in bytes
137+
content = File.read(cgroup_v2_path("memory.stat")) rescue nil
138+
if content && (m = content.match(/^file\s+(\d+)/m))
139+
return m[1].to_i
140+
end
141+
end
142+
143+
if File.exist?(cgroup_v1_path("memory.stat"))
144+
# cgroups v1: "cache" is page cache, in bytes
145+
content = File.read(cgroup_v1_path("memory.stat")) rescue nil
146+
if content && (m = content.match(/^cache\s+(\d+)/m))
147+
return m[1].to_i
148+
end
149+
end
150+
151+
# meminfo: Cached + Buffers + SReclaimable (kB)
152+
unless meminfo_content = self.meminfo
153+
return nil
154+
end
155+
cached_kb = meminfo_content[/Cached:\s*(\d+)\s*kB/, 1]&.to_i || 0
156+
buffers_kb = meminfo_content[/Buffers:\s*(\d+)\s*kB/, 1]&.to_i || 0
157+
sreclaimable_kb = meminfo_content[/SReclaimable:\s*(\d+)\s*kB/, 1]&.to_i || 0
158+
reclaimable_kb = cached_kb + buffers_kb + sreclaimable_kb
159+
return reclaimable_kb * 1024
160+
end
118161
end
119162
end
120163
end

test/process/host/memory.rb

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Released under the MIT License.
44
# Copyright, 2025-2026, by Samuel Williams.
55

6+
require "tmpdir"
67
require "process/metrics"
78

89
describe Process::Metrics::Host::Memory do
@@ -29,12 +30,22 @@
2930
end
3031
end
3132

33+
it "may include reclaimable_size (Linux: page cache etc., included in used_size)" do
34+
host = Process::Metrics::Host::Memory.capture
35+
skip "Host::Memory is not available on this platform" unless host
36+
if host.reclaimable_size != nil
37+
expect(host.reclaimable_size).to be_a(Integer)
38+
expect(host.reclaimable_size).to be >= 0
39+
expect(host.reclaimable_size).to be <= host.used_size
40+
end
41+
end
42+
3243
it "serializes to JSON" do
3344
host = Process::Metrics::Host::Memory.capture
3445
skip "Host::Memory is not available on this platform" unless host
3546
json = host.to_json
3647
parsed = JSON.parse(json)
37-
expect(parsed).to have_keys("total_size", "used_size", "free_size")
48+
expect(parsed).to have_keys("total_size", "used_size", "free_size", "available_size", "reclaimable_size")
3849
end
3950
end
4051

@@ -46,3 +57,27 @@
4657
end
4758
end
4859
end
60+
61+
if defined?(Process::Metrics::Host::Memory::Linux)
62+
describe Process::Metrics::Host::Memory::Linux do
63+
with "fake cgroup_root" do
64+
it "reads total_size, used_size, reclaimable_size from fake cgroup v2 files" do
65+
skip "Only runs on Linux" unless RUBY_PLATFORM.include?("linux")
66+
Dir.mktmpdir do |dir|
67+
total_bytes = 1_073_741_824
68+
used_bytes = 800_000_000
69+
file_reclaimable_bytes = 123_456_789
70+
File.write(File.join(dir, "memory.max"), total_bytes.to_s)
71+
File.write(File.join(dir, "memory.current"), used_bytes.to_s)
72+
File.write(File.join(dir, "memory.stat"), "anon 0\nfile #{file_reclaimable_bytes}\nkernel 0\n")
73+
host = Process::Metrics::Host::Memory::Linux.new(cgroup_root: dir).capture
74+
expect(host).to be_a(Process::Metrics::Host::Memory)
75+
expect(host.total_size).to be == total_bytes
76+
expect(host.used_size).to be == used_bytes
77+
expect(host.free_size).to be == total_bytes - used_bytes
78+
expect(host.reclaimable_size).to be == file_reclaimable_bytes
79+
end
80+
end
81+
end
82+
end
83+
end

0 commit comments

Comments
 (0)