Skip to content

Commit 6b4b980

Browse files
committed
Improve naming conventions.
- Extract `memory_usage` into `System` module. - Add `memory_usages` for cluster monitoring. - Add `maximum_size_limit` per monitor.
1 parent e00eb83 commit 6b4b980

File tree

5 files changed

+140
-90
lines changed

5 files changed

+140
-90
lines changed

lib/memory/leak/cluster.rb

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,19 @@ module Leak
1414
class Cluster
1515
# Create a new cluster.
1616
#
17-
# @parameter limit [Numeric | Nil] The (total) memory limit for the cluster.
18-
def initialize(limit: nil)
19-
@limit = limit
17+
# @parameter total_size_limit [Numeric | Nil] The total memory limit for the cluster.
18+
def initialize(total_size_limit: nil)
19+
@total_size = nil
20+
@total_size_limit = total_size_limit
2021

2122
@processes = {}
2223
end
2324

2425
# @returns [Hash] A serializable representation of the cluster.
2526
def as_json(...)
2627
{
27-
limit: @limit,
28+
total_size: @total_size,
29+
total_size_limit: @total_size_limit,
2830
processes: @processes.transform_values(&:as_json),
2931
}
3032
end
@@ -34,8 +36,11 @@ def to_json(...)
3436
as_json.to_json(...)
3537
end
3638

37-
# @attribute [Numeric | Nil] The memory limit for the cluster.
38-
attr_accessor :limit
39+
# @attribute [Numeric | Nil] The total size of the cluster.
40+
attr :total_size
41+
42+
# @attribute [Numeric | Nil] The total size limit for the cluster, in bytes, if which is exceeded, the cluster will terminate processes.
43+
attr_accessor :total_size_limit
3944

4045
# @attribute [Hash(Integer, Monitor)] The process IDs and monitors in the cluster.
4146
attr :processes
@@ -53,37 +58,47 @@ def remove(process_id)
5358
# Apply the memory limit to the cluster. If the total memory usage exceeds the limit, yields each process ID and monitor in order of maximum memory usage, so that they could be terminated and/or removed.
5459
#
5560
# @yields {|process_id, monitor| ...} each process ID and monitor in order of maximum memory usage, return true if it was terminated to adjust memory usage.
56-
def apply_limit!(limit = @limit)
57-
total = @processes.values.map(&:current).sum
61+
def apply_limit!(total_size_limit = @total_size_limit)
62+
@total_size = @processes.values.map(&:current_size).sum
5863

59-
if total > limit
60-
Console.warn(self, "Total memory usage exceeded limit.", total: total, limit: limit)
64+
if @total_size > total_size_limit
65+
Console.warn(self, "Total memory usage exceeded limit.", total_size: @total_size, total_size_limit: total_size_limit)
66+
else
67+
return false
6168
end
6269

6370
sorted = @processes.sort_by do |process_id, monitor|
64-
-monitor.current
71+
-monitor.current_size
6572
end
6673

6774
sorted.each do |process_id, monitor|
68-
if total > limit
69-
if yield process_id, monitor, total
70-
total -= monitor.current
71-
end
75+
if @total_size > total_size_limit
76+
yield(process_id, monitor, @total_size)
77+
78+
# For the sake of the calculation, we assume that the process has been terminated:
79+
@total_size -= monitor.current_size
7280
else
7381
break
7482
end
7583
end
7684
end
7785

86+
# Sample the memory usage of all processes in the cluster.
87+
def sample!
88+
System.memory_usages(@processes.keys).each do |process_id, memory_usage|
89+
@processes[process_id].current_size = memory_usage
90+
end
91+
end
92+
7893
# Check all processes in the cluster for memory leaks.
7994
#
8095
# @yields {|process_id, monitor| ...} each process ID and monitor that is leaking or exceeds the memory limit.
8196
def check!(&block)
97+
self.sample!
98+
8299
leaking = []
83100

84101
@processes.each do |process_id, monitor|
85-
monitor.sample!
86-
87102
if monitor.leaking?
88103
Console.debug(self, "Memory Leak Detected!", process_id: process_id, monitor: monitor)
89104

@@ -94,7 +109,7 @@ def check!(&block)
94109
leaking.each(&block)
95110

96111
# Finally, apply any per-cluster memory limits:
97-
apply_limit!(@limit, &block) if @limit
112+
apply_limit!(@total_size_limit, &block) if @total_size_limit
98113
end
99114
end
100115
end

lib/memory/leak/monitor.rb

Lines changed: 73 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,48 +7,50 @@
77

88
module Memory
99
module Leak
10-
# Detects memory leaks by tracking heap size increases.
10+
# Detects memory leaks by tracking process size increases.
1111
#
1212
# A memory leak is characterised by the memory usage of the application continuing to rise over time. We can detect this by sampling memory usage and comparing it to the previous sample. If the memory usage is higher than the previous sample, we can say that the application has allocated more memory. Eventually we expect to see this stabilize, but if it continues to rise, we can say that the application has a memory leak.
1313
#
1414
# We should be careful not to filter historical data, as some memory leaks may only become apparent after a long period of time. Any kind of filtering may prevent us from detecting such a leak.
1515
class Monitor
16-
# We only track heap size changes greater than this threshold, across the DEFAULT_INTERVAL.
17-
# True memory leaks will eventually hit this threshold, while small fluctuations will not.
18-
DEFAULT_THRESHOLD = 1024*1024*10
16+
# We only track process size changes greater than this threshold_size, across the DEFAULT_INTERVAL.
17+
# True memory leaks will eventually hit this threshold_size, while small fluctuations will not.
18+
DEFAULT_THRESHOLD_SIZE = 1024*1024*10
1919

20-
# We track the last N heap size increases.
21-
# If the heap size is not stabilizing within the specified limit, we can assume there is a leak.
22-
# With a default interval of 10 seconds, this will track the last ~3 minutes of heap size increases.
23-
DEFAULT_LIMIT = 20
20+
# We track the last N process size increases.
21+
# If the process size is not stabilizing within the specified increase_limit, we can assume there is a leak.
22+
# With a default interval of 10 seconds, this will track the last ~3 minutes of process size increases.
23+
DEFAULT_INCREASE_LIMIT = 20
2424

2525
# Create a new monitor.
2626
#
27-
# @parameter maximum [Numeric] The initial maximum heap size, from which we willl track increases, in bytes.
28-
# @parameter threshold [Numeric] The threshold for heap size increases, in bytes.
29-
# @parameter limit [Numeric] The limit for the number of heap size increases, before we assume a memory leak.
30-
# @parameter [Integer] The process ID to monitor.
31-
def initialize(process_id = Process.pid, maximum: nil, threshold: DEFAULT_THRESHOLD, limit: DEFAULT_LIMIT)
27+
# @parameter process_id [Integer] The process ID to monitor.
28+
# @parameter maximum_size [Numeric] The initial process size, from which we willl track increases, in bytes.
29+
# @parameter maximum_size_limit [Numeric | Nil] The maximum process size allowed, in bytes, before we assume a memory leak.
30+
# @parameter threshold_size [Numeric] The threshold for process size increases, in bytes.
31+
# @parameter increase_limit [Numeric] The limit for the number of process size increases, before we assume a memory leak.
32+
def initialize(process_id = Process.pid, maximum_size: nil, maximum_size_limit: nil, threshold_size: DEFAULT_THRESHOLD_SIZE, increase_limit: DEFAULT_INCREASE_LIMIT)
3233
@process_id = process_id
3334

34-
@maximum = maximum
35-
@threshold = threshold
36-
@limit = limit
35+
@current_size = nil
36+
@maximum_size = maximum_size
37+
@maximum_size_limit = maximum_size_limit
3738

38-
# The number of increasing heap size samples.
39-
@count = 0
40-
@current = nil
39+
@threshold_size = threshold_size
40+
@increase_count = 0
41+
@increase_limit = increase_limit
4142
end
4243

4344
# @returns [Hash] A serializable representation of the cluster.
4445
def as_json(...)
4546
{
4647
process_id: @process_id,
47-
current: @current,
48-
maximum: @maximum,
49-
threshold: @threshold,
50-
limit: @limit,
51-
count: @count,
48+
current_size: @current_size,
49+
maximum_size: @maximum_size,
50+
maximum_size_limit: @maximum_size_limit,
51+
threshold_size: @threshold_size,
52+
increase_count: @increase_count,
53+
increase_limit: @increase_limit,
5254
}
5355
end
5456

@@ -60,65 +62,80 @@ def to_json(...)
6062
# @attribute [Integer] The process ID to monitor.
6163
attr :process_id
6264

63-
# @attribute [Numeric] The current maximum heap size.
64-
attr :maximum
65+
# @attribute [Numeric] The maximum process size observed.
66+
attr_accessor :maximum_size
6567

66-
# @attribute [Numeric] The threshold for heap size increases.
67-
attr :threshold
68+
# @attribute [Numeric | Nil] The maximum process size allowed, before we assume a memory leak.
69+
attr_accessor :maximum_size_limit
6870

69-
# @attribute [Numeric] The limit for the number of heap size increases, before we assume a memory leak.
70-
attr :limit
71+
# @attribute [Numeric] The threshold_size for process size increases.
72+
attr_accessor :threshold_size
7173

72-
# @attribute [Integer] The number of increasing heap size samples.
73-
attr :count
74+
# @attribute [Integer] The number of increasing process size samples.
75+
attr_accessor :increase_count
7476

75-
# The current resident set size (RSS) of the process.
76-
#
77-
# Even thought the absolute value of this number may not very useful, the relative change is useful for detecting memory leaks, and it works on most platforms.
78-
#
79-
# @returns [Numeric] Memory usage size in bytes.
80-
private def memory_usage
81-
IO.popen(["ps", "-o", "rss=", @process_id.to_s]) do |io|
82-
return Integer(io.readlines.last) * 1024
83-
end
77+
# @attribute [Numeric] The limit for the number of process size increases, before we assume a memory leak.
78+
attr_accessor :increase_limit
79+
80+
def memory_usage
81+
System.memory_usage(@process_id)
8482
end
8583

8684
# @returns [Integer] The last sampled memory usage.
87-
def current
88-
@current ||= memory_usage
85+
def current_size
86+
@current_size ||= memory_usage
87+
end
88+
89+
# Set the current memory usage, rather than sampling it.
90+
def current_size=(value)
91+
@current_size = value
8992
end
9093

9194
# Indicates whether a memory leak has been detected.
9295
#
93-
# If the number of increasing heap size samples is greater than or equal to the limit, a memory leak is assumed.
96+
# If the number of increasing heap size samples is greater than or equal to the increase_limit, a memory leak is assumed.
97+
#
98+
# @returns [Boolean] True if a memory leak has been detected.
99+
def increase_limit_exceeded?
100+
@increase_count >= @increase_limit
101+
end
102+
103+
# Indicates that the current memory usage has grown beyond the maximum size limit.
104+
#
105+
# @returns [Boolean] True if the current memory usage has grown beyond the maximum size limit.
106+
def maximum_size_limit_exceeded?
107+
@maximum_size_limit && self.current_size > @maximum_size_limit
108+
end
109+
110+
# Indicates whether a memory leak has been detected.
94111
#
95112
# @returns [Boolean] True if a memory leak has been detected.
96113
def leaking?
97-
@count >= @limit
114+
increase_limit_exceeded? || maximum_size_limit_exceeded?
98115
end
99116

100117
# Capture a memory usage sample and yield if a memory leak is detected.
101118
#
102119
# @yields {|sample, monitor| ...} If a memory leak is detected.
103120
def sample!
104-
@current = memory_usage
121+
self.current_size = memory_usage
105122

106-
if @maximum
107-
delta = @current - @maximum
108-
Console.debug(self, "Heap size captured.", current: @current, delta: delta, threshold: @threshold, maximum: @maximum)
123+
if @maximum_observed_size
124+
delta = @current_size - @maximum_observed_size
125+
Console.debug(self, "Heap size captured.", current_size: @current_size, delta: delta, threshold_size: @threshold_size, maximum_observed_size: @maximum_observed_size)
109126

110-
if delta > @threshold
111-
@maximum = @current
112-
@count += 1
127+
if delta > @threshold_size
128+
@maximum_observed_size = @current_size
129+
@increase_count += 1
113130

114-
Console.debug(self, "Heap size increased.", maximum: @maximum, count: @count)
131+
Console.debug(self, "Heap size increased.", maximum_observed_size: @maximum_observed_size, count: @count)
115132
end
116133
else
117-
Console.debug(self, "Initial heap size captured.", current: @current)
118-
@maximum = @current
134+
Console.debug(self, "Initial heap size captured.", current_size: @current_size)
135+
@maximum_observed_size = @current_size
119136
end
120137

121-
return @current
138+
return @current_size
122139
end
123140
end
124141
end

lib/memory/leak/system.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,26 @@ def self.total_memory_size
2929
end
3030
end
3131
end
32+
33+
# Get the memory usage of the given process IDs.
34+
#
35+
# @parameter process_ids [Array(Integer)] The process IDs to monitor.
36+
# @returns [Array(Tuple(Integer, Integer))] The memory usage of the given process IDs.
37+
def self.memory_usages(process_ids)
38+
IO.popen(["ps", "-o", "pid=,rss=", *process_ids.map(&:to_s)]) do |io|
39+
io.each_line.map(&:split).map{|process_id, size| [process_id.to_i, size.to_i * 1024]}
40+
end
41+
end
42+
43+
# Get the memory usage of the given process IDs.
44+
#
45+
# @parameter process_ids [Array(Integer)] The process IDs to monitor.
46+
# @returns [Array(Tuple(Integer, Integer))] The memory usage of the given process IDs.
47+
def self.memory_usage(process_id)
48+
IO.popen(["ps", "-o", "rss=", process_id.to_s]) do |io|
49+
return io.read.to_i * 1024
50+
end
51+
end
3252
end
3353
end
3454
end

test/memory/leak/cluster.rb

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@
1717
before do
1818
@children = 3.times.map do
1919
child = Memory::Leak::LeakingChild.new
20-
monitor = cluster.add(child.process_id, limit: 10)
20+
monitor = cluster.add(child.process_id, increase_limit: 10)
2121

2222
[child.process_id, child]
2323
end.to_h
2424
end
2525

2626
after do
27-
@children.each_value do |child|
27+
@children&.each_value do |child|
2828
child.close
2929
end
3030
end
@@ -37,7 +37,7 @@
3737

3838
# Force the first child to trigger the memory leak:
3939
until monitor.leaking?
40-
child.write_message(action: "allocate", size: monitor.threshold + 1)
40+
child.write_message(action: "allocate", size: monitor.threshold_size + 1)
4141
child.wait_for_message("allocated")
4242

4343
# Capture a sample of the memory usage:
@@ -46,7 +46,7 @@
4646

4747
cluster.check! do |process_id, monitor|
4848
expect(process_id).to be == child.process_id
49-
expect(monitor.count).to be == monitor.limit
49+
expect(monitor.increase_count).to be == monitor.increase_limit
5050
expect(monitor).to be(:leaking?)
5151

5252
child.close
@@ -63,35 +63,33 @@
6363

6464
it "can apply memory limit" do
6565
# 100 MiB limit:
66-
cluster.limit = 1024*1024*100
66+
cluster.total_size_limit = 1024*1024*100
6767

6868
big_child = children.values.first
6969
big_monitor = cluster.processes[big_child.process_id]
7070
small_child = children.values.last
7171
small_monitor = cluster.processes[small_child.process_id]
7272

73-
big_allocation = (cluster.limit * 0.8).floor - big_monitor.current
73+
big_allocation = (cluster.total_size_limit * 0.8).floor - big_monitor.current_size
7474
if big_allocation > 0
7575
big_child.write_message(action: "allocate", size: big_allocation)
7676
big_child.wait_for_message("allocated")
7777
end
7878

79-
small_allocation = (cluster.limit * 0.2).floor - small_monitor.current
79+
small_allocation = (cluster.total_size_limit * 0.2).floor - small_monitor.current_size
8080
if small_allocation > 0
8181
small_child.write_message(action: "allocate", size: small_allocation)
8282
small_child.wait_for_message("allocated")
8383
end
8484

8585
# The total memory usage is 110% of the limit, so the biggest child should be terminated:
86-
cluster.check! do |process_id, monitor, total|
86+
cluster.check! do |process_id, monitor, total_size|
8787
expect(process_id).to be == big_child.process_id
8888
expect(monitor).not.to be(:leaking?)
8989

9090
big_child.close
9191
children.delete(process_id)
9292
cluster.remove(process_id)
93-
94-
true
9593
end
9694

9795
expect_console.to have_logged(

0 commit comments

Comments
 (0)