Elixir by Example

Hot Code Loading

Edit any cell and run it in your browser Download source

The BEAM can replace a module's code while the system runs, without stopping a single process. That's how a remote shell can patch a bug in production, and how iex -S mix recompiles as you edit. It's also easy to get wrong, because which version a process runs depends on how it calls its own functions.

The rules:

  1. A module can have two versions loaded at once: current and old. Loading a new version makes the current one old.
  2. A process keeps running the version it's in until it makes a fully qualified call (Module.fun()), which always goes to the current version. A local call (fun()) stays in the version it's in.
  3. Loading a third version needs the old one gone first. Purging it kills every process still running it.

The demo runs two looping processes on version 1, loads version 2, and watches one move to it and the other stay behind; then it purges the old version.

Redefining a module in a script normally warns; this page does it on purpose, so the warning is switched off.

Code.put_compiler_option(:ignore_module_conflict, true)

Version 1. Both loops answer {:ask, from} with hello/0. local_loop/0 loops with a local call; remote_loop/0 loops with a fully qualified one.

defmodule Greeting do
  def hello, do: "v1"

  def local_loop do
    receive do
      {:ask, from} ->
        send(from, hello())
        local_loop()
    end
  end

  def remote_loop do
    receive do
      {:ask, from} ->
        send(from, hello())
        __MODULE__.remote_loop()
    end
  end
end

local = spawn(&Greeting.local_loop/0)
remote = spawn(&Greeting.remote_loop/0)

ask = fn pid ->
  send(pid, {:ask, self()})

  receive do
    answer -> answer
  end
end

IO.inspect({ask.(local), ask.(remote)}, label: "before the upgrade")
before the upgrade: {"v1", "v1"}

Version 2. Defining the module again compiles it and loads it while both processes wait in receive. Each is still inside version 1's code.

defmodule Greeting do
  def hello, do: "v2"

  def local_loop do
    receive do
      {:ask, from} ->
        send(from, hello())
        local_loop()
    end
  end

  def remote_loop do
    receive do
      {:ask, from} ->
        send(from, hello())
        __MODULE__.remote_loop()
    end
  end
end

The first answer from each comes from version 1: the receive that got the message was v1 code. After it, remote_loop calls Greeting.remote_loop() and jumps to version 2; local_loop calls local_loop() and stays in version 1 for good.

IO.inspect({ask.(local), ask.(remote)}, label: "first ask after")
IO.inspect({ask.(local), ask.(remote)}, label: "second ask after")
first ask after: {"v1", "v1"}
second ask after: {"v1", "v2"}

Version 1 is now old code, and local still runs it. :code.soft_purge/1 removes old code only if no process uses it, so it refuses. :code.purge/1 removes it anyway, killing every process still running it.

IO.inspect(:code.soft_purge(Greeting), label: "soft purge")
IO.inspect(:code.purge(Greeting), label: "purge")
IO.inspect({Process.alive?(local), Process.alive?(remote)}, label: "alive (local, remote)")
soft purge: false
purge: true
alive (local, remote): {false, true}

Pitfall: a long-running process must reach a fully qualified call regularly, or an upgrade leaves it behind and the next upgrade kills it.

GenServers, Agents and other OTP processes

OTP behaviours never get left behind. A GenServer's receive loop lives in :gen_server, not in your module, and it calls your callbacks fully qualified (Counter.handle_call(...)). So the next message after a load runs the new callbacks. The state isn't touched: it's data the process holds, not part of the code. Agent, Task.Supervisor, Supervisor and :gen_statem work the same way.

Two counters, so we can later migrate one and not the other. A crash in the unmigrated one is part of the demo, so the crash report is silenced.

Logger.configure(level: :none)

defmodule Counter do
  use GenServer

  def init(n), do: {:ok, n}
  def handle_call(:bump, _from, n), do: {:reply, n + 1, n + 1}
  def handle_call(:peek, _from, n), do: {:reply, n, n}
end

{:ok, a} = GenServer.start(Counter, 0)
{:ok, b} = GenServer.start(Counter, 0)
for pid <- [a, b], _ <- 1..2, do: GenServer.call(pid, :bump)
IO.inspect(GenServer.call(a, :peek), label: "v1 peek")
v1 peek: 2

Version 2 changes what the callbacks return, but the state is still a plain integer.

defmodule Counter do
  use GenServer

  def init(n), do: {:ok, n}
  def handle_call(:bump, _from, n), do: {:reply, n + 10, n + 10}
  def handle_call(:peek, _from, n), do: {:reply, {:count, n}, n}
end

The count survived (2), and the very next call already ran version 2's code. No process is inside version 1's code, so a soft purge succeeds: nobody gets killed.

IO.inspect(GenServer.call(a, :peek), label: "v2 peek")
IO.inspect(:code.soft_purge(Counter), label: "soft purge")
v2 peek: {:count, 2}
soft purge: true

Version 3 changes the shape of the state: a map instead of an integer. Loading it doesn't change the integers both processes hold, so the new callbacks get state they don't understand.

defmodule Counter do
  use GenServer

  def init(n), do: {:ok, %{count: n, bumps: 0}}

  def handle_call(:bump, _from, s) do
    {:reply, s.count + 1, %{s | count: s.count + 1, bumps: s.bumps + 1}}
  end

  def handle_call(:peek, _from, s), do: {:reply, s, s}

  # Turns the old state into the new shape. OTP calls it
  # only when asked to (below), never on load.
  def code_change(_old_vsn, n, _extra) when is_integer(n) do
    {:ok, %{count: n, bumps: 0}}
  end
end

IO.inspect(:sys.get_state(b), label: "b's state after load")

try do
  GenServer.call(b, :bump)
catch
  :exit, {{reason, _stack}, _call} -> IO.inspect(reason, label: "b crashed")
end
b's state after load: 2
b crashed: {:badmap, 2}

For a, we migrate first. :sys.suspend/1 stops it handling messages (callers wait), :sys.change_code/4 runs code_change/3 inside the process, and :sys.resume/1 lets it carry on with the new state.

:ok = :sys.suspend(a)
IO.inspect(:sys.change_code(a, Counter, :v2, nil), label: "change_code")
:ok = :sys.resume(a)

IO.inspect(GenServer.call(a, :bump), label: "a bump")
IO.inspect(GenServer.call(a, :peek), label: "a peek")
change_code: :ok
a bump: 3
a peek: %{count: 3, bumps: 1}

This suspend, load, change_code, resume dance is what a release upgrade (an appup) does for every process whose child spec lists the module under modules:. Most teams skip it: they keep the state shape compatible, or roll out new nodes and let processes restart with fresh state (see Rolling Deploys).

Pitfall: use GenServer defines a default code_change/3 that returns the state unchanged. If you change the state shape and forget to write your own, a release upgrade "succeeds" and the process crashes on its next message, like b did.

Pitfall: an anonymous function remembers the version of the module that created it. Keeping one in state (or in ETS) pins that old version, and a purge kills whoever calls it. This is the trap with Agent, whose state is often built from closures: Agent.update(pid, fn s -> ... end) runs the fun and forgets it, which is fine, but storing a fun in the state isn't. Store &Mod.fun/arity captures or {Mod, :fun, args} tuples instead.

Go deeper: chapter 8 of The BEAM Book by Erik Stenman, "Different Types of Calls, Linking and Hot Code Loading", shows what local, remote and closure calls compile to, and why that decides which version runs.