Robust Session Storage in Phoenix Live View Sessions
For an internal project called ‘ControlManiac’, which helps us with all the boring numbers juggling in our software studio, I had to add a global filter to the navigation bar, which lets me select the division I want to filter the data for on whatever view I am currently on.
Since we often switch views while using this app, it should keep its selection. It should also keep it upon reloading or revisiting the page and upon deployment of a new version.
This is what it looks like; it is the toggle element on the right:
The Home, Projects, and Costs navigation options are all separate live views, within the same live_session
.
I thought there would be a quite easy solution, but it turned out that all common approaches had their downsides:
- Keeping it in the
assigns
only is not an option, since this is cleared upon changing the current live view. - Storing it in a session cookie would ensure it survives a full page reload but brings two problems: First, the session information is not reloaded upon changing the live views within the same
live_session
, because this is done entirely via web sockets. Thus, we would get the initial value the session head at the time of the first HTTP request. This brings me to the second problem: I neither can update the session since there is no HTTP request upon live navigation, and the session cookie cannot be updated via web socket. - Storing it in an ETS table solves the problem of switching live views within the same
live_session
, but of course, it would not survive an app restart, as is the case upon deployment of a new version.
I could, of course, pursue the session approach and just make sure to add a parameter every time I switch between the main live views, but this does feel very odd.
I eventually decided to go with a classic cookie-based session combined with an additional layer stored in an ETS table.
Preparing the pipeline
To be able to load data via plug and on_mount
, we first implement a module ControlManiacWeb.DivisionSelector
and added it to the pipline:
elixirdefmodule ControlManiacWeb.Router do
use ControlManiacWeb, :router
pipeline :browser do
...
plug :fetch_current_division
end
...
scope "/", ControlManiacWeb do
pipe_through [...]
live_session :require_authenticated_user,
on_mount: [
...,
{ControlManiacWeb.DivisionSelector, :mount_current_division}
] do
...
end
end
...
The basic idea for these two functions is as follows:
fetch_current_division
pug is added to the pipeline. Its task is to fetch the current division from cache or session and add it toconn.assigns
.mount_current_division
is used for the live views. Its task is to load the current division from cache and add it tosocket.assigns
.
Storing settings in the ETS table
Before we come to the implementation of these functions, lets have a look at how we implement the cache using an ETS table.
elixirdefmodule ControlManiac.SettingsCache do
use GenServer
@name __MODULE__
@tab :settings_cache
@ttl 60 * 60 * 24 * 31
# Client
def start_link(_), do: GenServer.start_link(__MODULE__, [], name: @name)
def insert(key, value) do
expiration = :os.system_time(:seconds) + @ttl
:ets.insert(@tab, {key, value, expiration})
end
def get(key, default \\ nil) do
lookup =
case :ets.lookup(@tab, key) do
[{_, value, _} | _] -> value
[] -> default
end
lookup
end
# Server
def init(_) do
:ets.new(
@tab,
[:set, :named_table, :public, read_concurrency: true, write_concurrency: true]
)
{:ok, []}
end
end
What this module does is quite simple. It gets up the table within a GenServer
, to ensure it exists when the application starts. It then offers functions to insert and get entries, which directly write into the ETS table to avoid the GenServer
becoming a bottleneck.
Retrieving data
We now can use our SettingsCache together with normal sessions to implement the desired behavior for the pipeline. Please note that this implementation assumes we have a user present for whom we can store the data, and that we want to store the data on a per-user basis.
elixirdefmodule ControlManiacWeb.DivisionSelector do
import Plug.Conn
alias ControlManiac.Accounts.User
alias ControlManiac.SettingsCache
@default_division -1
@session_division_key "current_division_id"
@cache_division_key :division_cache
def fetch_current_division(conn, _opts) do
current_user = conn.assigns[:current_user]
assign(
conn,
:current_division,
get_division_from_cache(current_user) ||
get_division_from_session(conn, current_user) ||
@default_division
)
end
defp get_division_from_cache(nil), do: nil
defp get_division_from_cache(%User{id: user_id}) do
SettingsCache.get({@cache_division_key, user_id}, nil)
end
defp get_division_from_session(conn) do
get_session(conn, @session_division_key)
end
...
end
This code first attempts to get the current division from the cache. If this is not successful, it tries to get it from the session, reverting to a default value (which is hard-coded to -1
/ ‘All’) if this fails, too.
Handling data for live views
So here comes the implementation of the on_mount/4
function used for live views:
elixirdef on_mount(:mount_current_division, _params, session, socket) do
division_id =
get_division_from_cache(socket.assigns[:current_user]) ||
get_division_from_session_map(session) ||
@default_division
{
:cont,
Phoenix.Component.assign(
socket,
:current_division,
division_id
)
}
end
As you might notice, there again is a fallback to the session map with get_division_from_session_map/1
, although I mentioned at the beginning that this will include old values from the first http request, and not the value the user selected last. So why do we do that?
We do this to handle a specific edge case: In case the server restarts and the socket connection is initiated again, we lost our cache. Upon reconnecting the socket there is no run through the plug pipeline (so, no fetch_current_division/2
invoked), but since reconnection is done via XHR request, we get updated session info. This is especially useful after deployments: When the system reconnects, we get the correct value from the session.
We need the fallback to session info anyway because of this edge case, so we can also use it as a fallback for the first requests, where the ETS table cache might not be warm although a division is set in the session. This way, we don’t have to make sure the cache is up to date in fetch_current_division/2:
We use the value from the initial session, and as soon as the user changes the division, the ETS table cache entry is created and the fallback will never be reached.
Since in on_mount/4
we get the information stored in the session as a map, the function to retrieve it is slightly different:
elixirdefp get_division_from_session_map(session) do
if division_id = Map.get(session, @session_division_key) do
String.to_integer(division_id)
else
nil
end
end
Storing data in session despite using sockets
So how do we make sure to store the most recent value in the session, given that by navigating only within a single live_session
, we won’t have HTTP requests?
We do so by triggering a JS hook, which then performs an XHR request to store the actual data, an idea I copied from FullstackPhoenix.
We first implement a controller with the sole purpose of adding data to the session, given it is within a list of allowed keys:
elixirdefmodule ControlManiacWeb.StoreSessionController do
use ControlManiacWeb, :controller
@allowed_keys ~w(current_division_id)
def create(conn, params) do
updated_conn =
Enum.reduce(params, conn, fn {key, value}, acc_conn ->
if key in @allowed_keys do
put_session(acc_conn, String.to_atom(key), value)
else
acc_conn
end
end)
send_resp(updated_conn, 200, "")
end
end
In order to have something we can hook the JS to we add a div to the root layout:
html<div id="store-session" phx-hook="StoreSession"></div>
We can now attach a JS handler to the phx-hook
in order to trigger the actual request.
js// store_session.js
export const StoreSession = {
mounted() {
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
this.handleEvent('store_session', data => {
fetch('/store_session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
body: JSON.stringify(data)
})
})
}
}
// And don't forger to register the hook in app.js
import { StoreSession } from "./store_session"
let liveSocket = new LiveSocket("/live", Socket, {
...
hooks: {
StoreSession: StoreSession
}
})
At last, we have to make sure this hook is triggered upon selecting another division. For this, we have to do two things: Tell the DivisionSelector
module we implemented in the beginning to store the updated version to the cache, and triggering the hook to store it in the session.
elixirdef handle_event("switch-division", %{"id" => id}, socket) do
DivisionSelector.update_division(
socket.assigns.current_user,
String.to_integer(id)
)
socket =
socket
|> push_event("store_session", DivisionSelector.map_for_session(id))
|> assign(:current_division, id)
{:noreply, socket}
end
To make it work, we add the following two functions to the DivisionSelector
:
elixirdef update_division(%User{id: user_id} = user, division_id) do
SettingsCache.insert({@cache_division_key, user_id}, division_id)
end
def map_for_session(id) do
%{@session_division_key => id}
end
Note we added map_for_session/1
for consistency, to ensure the keys used to store data in cache and session are defined in one single place.
In the real application, we also used Phoenix.PubSub
to notify current views about the division change, which might be a topic for another blog post some time.
Conclusion
Given that this is what I thought to be a very simple problem, I was quite surprised that I was not able to find an easier solution. But, on the other hand, handling global state is hard, no matter the framework, and at least within Elixir Phoenix, we are able to implement a solution in a very explicit and controllable way.