In the
last article I discussed how Elixir manages to claim the title of soft real-time through reliable process management. Now we'll take a look at how Elixir manages hot code reloads on individual modules.
What is Hot-Swapping?
Hot-swapping is the ability to change code without shutting down the system in the process. This allows the system to continue serving requests while we upgrade code. This is useful if we need our systems to be highly available. This mechanism allows us to fix bugs on the fly or even add new functionality without going offline.
How Does Hot-Swapping Work?
Erlang was designed in a way that allows for multiple versions of a module to be run at the same time. This allows us to load new versions of the modules into the system while old versions are still running. The VM can run 2 versions of a module simultaneously. When new module versions are loaded, the existing processes will continue to run unless there are already 2 versions of the module being used, in which case the processes running the oldest version would be killed. Newly-spawned processes will load the new version of the code.
When a new version of a module is loaded, all remote calls to the module are replaced to point to the new version. However, local calls in running processes will continue to use the old functions. We can see this when running a module in iex and reloading it:
defmodule HelloWorld do
def hello(name) do
IO.puts("Hi, #{name}")
:timer.sleep(1000)
hello(name)
end
end
iex(1)> spawn(HelloWorld, :hello, ["Joe"])
#PID<0.119.0>
Hi, Joe
Hi, Joe
Hi, Joe
Now if we update the module we'll see the current process continues to use the old code:
defmodule HelloWorld do
def hello(name) do
IO.puts("Hello, #{name}")
:timer.sleep(1000)
hello(name)
end
end
We can use
r/1 to reload the module in iEX.
Hi, Joe
Hi, Joe
Hi, Joe
iex(2)> r(HelloWorld)
iex(3)> r HelloWorld
warning: redefining module HelloWorld (current version loaded from _build/dev/lib/elixir_playground/ebin/Elixir.HelloWorld.beam)
lib/hello_world.ex:1
Hi, Joe
iex(3)> spawn(HelloWorld, :hello, ["Joe"])
#PID<0.125.0>
Hello, Joe
Hi, Joe
Hello, Joe
As you can see, both of our processes are running but using different versions of the module. Now what happens if we add a third version while the old version is still running?
defmodule HelloWorld do
def hello(name) do
IO.puts("Goodbye, #{name}")
:timer.sleep(1000)
hello(name)
end
end
iex(5)> r HelloWorld
warning: redefining module HelloWorld (current version defined in memory)
lib/hello_world.ex:1
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
Hello, Joe
So what happened? Why do we only have one process running now? Erlang was designed to only run a maximum of 2 code versions at a time so the oldest version was removed and the process crashed.
Let's Take a Look at Hot-Swapping a GenServer
Due to the nature of GenServers, hot-swapping will reload slightly differently. As mentioned previously, all remote calls to a module will be replaced as soon as a new version is loaded into the VM. Modules that use GenServer are actually storing state and updating state in separate processes. The GenServer will create a loop which stores the state and then calls our module's callback functions. This means that all calls to our module are remote calls. As we learned earlier, remote calls are replaced as soon as the modules are reloaded. Let's take a look at this in action.
defmodule HelloWorld do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, "Joe")
end
def init(state) do
Process.send_after(self(), :hello, 1000)
{:ok, state}
end
def handle_info(:hello, name) do
IO.puts("Hi, #{name}")
Process.send_after(self(), :hello, 1000)
{:noreply, name}
end
end
Here we're using pattern matching on the handle_info callback to catch those Process.send_after/3 calls. This code behaves in the same way the original HelloWorld module. Now if we call HelloWorld.start_link/0 and then update and reload HelloWorld we'll see how GenServer behaves:
iex(1)> HelloWorld.start_link
{:ok, #PID<0.154.0>}
Hi, Joe
Hi, Joe
Hi, Joe
defmodule HelloWorld do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, "Joe")
end
def init(state) do
Process.send_after(self(), :hello, 1000)
{:ok, state}
end
def handle_info(:hello, name) do
IO.puts("Hello, #{name}")
Process.send_after(self(), :hello, 1000)
{:noreply, name}
end
end
iex(2)> r HelloWorld
warning: redefining module HelloWorld (current version loaded from _build/dev/lib/elixir_playground/ebin/Elixir.HelloWorld.beam)
lib/hello_world.ex:1
{:reloaded, HelloWorld, [HelloWorld]}
Hello, Joe
Hello, Joe
As we can see, the module was reloaded and the output instantly changed. This is due to GenServer calling handle_info on the module, making the call a remote call.
Handling State Changes
In our example we're not really managing any state. We are passing my name but this isn't really a state - it was just used for demonstration purposes. Let's imagine we are transforming a complex map full of nested data. Each time you reload code you need to ensure that your state is being managed correctly. GenServer provides a callback function we can implement to modify state when code changes.
code_change/3 receives 3 arguments: the old version of the module, the current state, and extra information required to make the change. This function is useful when you need to migrate your state to be compatible with the new module e.g.:
defmodule EnvState do
change_code(_old_vsn, state, _extra) do
status = state[:stored_status]
errors = state[:repo][:errors]
{:ok, %{status: status, errors: errors}
end
end
Wrapping UP
We've taken a quick look at how Elixir handles hot-swapping. There are a lot more details to look into but that's outside the scope of this series. If you're interested in hot-swapping code in your production environments I recommend looking into hot upgrades with distillery. Distillery simplifies the process of creating releases and helps with swapping out old modules.
In the next article we'll recap and see how these pieces all work together to deliver highly available systems.