Debugging a memory leak in elixir
Few days back, one of the operators running rudder reported that the memory overflowed after every few days.
overflow
Rudder is written in Elixir as it was an excellent language for
building fault-tolerant systems. It has a great concurrency model
(lightweight processes), has hot code upgrades, and is in general great
for writing softwares which need to run for a long time.
This looked like a memory leak, and the next step to do was figure out
where it was happening.
I used the observer graphical tool. It provides features
for monitoring and inspecting a runtime system – allowing you to trace
method calls, check ports and devices, and see detailed breakdown of
memory usage.
>> rudder
-> iex -S mix
Interactive Elixir (1.15.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Mix.ensure_application!(:wx)
:ok
iex(2)> Mix.ensure_application!(:runtime_tools)
:ok
iex(3)> Mix.ensure_application!(:observer)
:ok
iex(4)> :observer.start()
:ok
iex(5)>This launches the observer UI:
observer starting UI
I then start the rudder listener, which is the long running process:
iex(5)> Rudder.ProofChain.BlockSpecimenEventListener.start()
[info] starting event listener
[info] retrying older uprocessed bsps (if any) before starting to listen
[info] getting ids with status=discover
......With the rudder running, I went to the Memory Allocators
tab on the observer, and noticed steady rise in total memory usage (and
eheap_alloc) when left running for few minutes.
Rising Tide
So there was some kind of resource leak happening in rudder. But what
was it? The above graph showed that it was happening, and that it was
happening in the heap, but I needed to nail the issue down further.
After a bit of playing around and understanding the UI (chatGPT was
great at this btw), I went to the Processes tab and sorted
the entries by Memory
Leaking processes
It was apparent that the number of
Elixir.Rudder.Pipeline.Spawner.push_hash tasks were
growing, each of total size of around 7MBs, and never terminating. The
accumulation of such non-terminating tasks might be causing the leak.
This was also verified from the Applications tab, where the
Elixir.Rudder.Pipeline.Spawner was having more and more
childrens, while it was expected that the spawned tasks would end
(erased/GC'd) once the task was executed.
The push_hash function was this:
def push_hash(bsp_key, urls) do
DynamicSupervisor.start_child(
__MODULE__,
{Agent,
fn ->
Rudder.Pipeline.process_specimen(bsp_key, urls)
end}
)
end
endThe child process started is an Elixir Agent, which is actually an event-loop employing long-running process. That's not what we need! What we need is instead a "execute the task and terminate"-style behaviour. So, Tasks were the way to go:
def push_hash(bsp_key, urls) do
DynamicSupervisor.start_child(
__MODULE__,
{Task,
fn ->
Rudder.Pipeline.process_specimen(bsp_key, urls)
end}
)
end
endWith the above change, rudder went from being slowly memory-hogging, to having stable memory requirements.
Stable memory usage