jsm
Atomicity in mnesia, specifically Pow's MnesiaCache
I’ve been quite busy in the past week creating my own Pow-based session system
I’ve followed the API guide at https://hexdocs.pm/pow/api.html and then modified it quite heavily to suite my own needs, meaning that sessions now have an ID that is stable for the life of the session, separate from the ID of the refresh token. The tokens themselves are created using Phoenix tokens.
One reason for this setup is that I want to make it possible for a user to see all their current sessions, and log out sessions other than the current one. To do so, I’ve extended Pow’s built-in MnesiaCache with some extra methods. Everything works fine, but now that I start to test things, it seems that the operations in it are not atomic. I’ve used transactions in the code I’ve added myself and I can see transactions being used in Pow’s MnesiaCache, so this does not make sense to me.
The following code is my implementation with doctests and the test module. I’ve hacked around the problem by inserting Process.sleep(30) calls, which is obviously not ideal. Removing causes the tests to fail seemingly because things have not yet been inserted or deleted in mnesia.
Does anybody have an idea what the problem might be? Do I misunderstand mnesia transactions?
defmodule MyAppWeb.Sessions.MnesiaSessionStore do
@moduledoc """
Pow persistent session store using Mnesia.
"""
use Pow.Store.Base,
ttl: Application.get_env(:my_app_web, :auth)[:refresh_token_ttl],
namespace: "persistent_session"
alias MyAppWeb.Sessions.Session
# Mnesia table object format:
# {table, key, {session_id, session, [ttl: _, namespace: _], timestamp}}
@session_store Pow.Store.Backend.MnesiaCache
@store_config [backend: @session_store]
@doc """
Get a session by session id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session(session.id, session)
:ok
iex> Process.sleep(30)
iex> get_session(session.id)
session
"""
@spec get_session(binary) :: Session.t() | :not_found
def get_session(session_id), do: get(@store_config, session_id)
@doc """
Delete a session by session id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session(session.id, session)
:ok
iex> Process.sleep(30)
iex> delete_session(session.id)
:ok
iex> Process.sleep(30)
iex> get_session(session.id)
:not_found
"""
@spec delete_session(binary) :: :ok
def delete_session(session_id), do: delete(@store_config, session_id)
@doc """
Create or update a session by session id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session(session.id, session)
:ok
iex> Process.sleep(30)
iex> get_session(session.id)
session
"""
@spec put_session(binary, Session.t()) :: :ok
def put_session(session_id, session), do: put(@store_config, session_id, session)
@doc """
Get all sessions for the user identified by user_id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session("1", session)
:ok
iex> put_session("2", session)
:ok
iex> Process.sleep(30)
iex> get_all_sessions(session.user_id)
{:ok, [session, session]}
"""
@spec get_all_sessions(pos_integer) :: {:error, any} | {:ok, [Session.t()]}
def get_all_sessions(user_id) do
object_matcher = fn ->
:mnesia.match_object({@session_store, :_, {:_, %{user_id: user_id}, :_, :_}})
end
case :mnesia.transaction(object_matcher) do
{:atomic, results} -> {:ok, Enum.map(results, fn {_, _, {_, session, _, _}} -> session end)}
{:aborted, reason} -> {:error, reason}
end
end
@doc """
Delete all sessions for the user identified by user_id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session("1", session)
:ok
iex> put_session("2", session)
:ok
iex> Process.sleep(30)
iex> get_all_sessions(session.user_id)
{:ok, [session, session]}
iex> delete_all_sessions(session.user_id)
iex> Process.sleep(30)
iex> get_all_sessions(session.user_id)
{:ok, []}
"""
@spec delete_all_sessions(pos_integer) :: :ok | {:error, any}
def delete_all_sessions(user_id) do
object_deleter = fn ->
:mnesia.match_object({@session_store, :_, {:_, %{user_id: user_id}, :_, :_}})
|> Enum.each(fn {_, _, {id, _, _, _}} -> delete_session(id) end)
end
case :mnesia.transaction(object_deleter) do
{:atomic, _} -> :ok
{:aborted, reason} -> {:error, reason}
end
end
end
And the test module:
defmodule MyAppWeb.Sessions.MnesiaSessionStoreTest do
use ExUnit.Case
import MyAppWeb.Sessions.MnesiaSessionStore
setup do
case :mnesia.clear_table(Pow.Store.Backend.MnesiaCache) do
{:atomic, _} -> :ok
{:aborted, reason} -> {:error, reason}
end
end
def sample_session(),
do: %MyAppWeb.Sessions.Session{
id: "the_session",
user_id: 1,
last_known_ip: "127.0.0.1",
created_at: 123,
refreshed_at: 123
}
doctest MyAppWeb.Sessions.MnesiaSessionStore
end
Marked As Solved
jsm
I got it
Turns out that I missed something obvious after all. The delete call is here: https://github.com/danschultzer/pow/blob/master/lib/pow/store/backend/mnesia_cache.ex#L112 which is a GenServer cast which of course returns immediately without waiting for the result. So despite the fact that table_delete on line 247 uses a transaction, I can’t rely on MnesiaCache.delete/2 for atomicity / ordering. Thanks for your help @OvermindDL1!
@danschultzer would there be a drawback to going direcly to mnesia, bypassing that GenServer for delete/put/get? I can tell there’s a lot more stuff happening in there with those invalidators. Is there a reason you implemented it using casts? Wouldn’t it make more sense to use calls in this case, given that transactions are used in the casts’ implementations anyway?
Also Liked
danschultzer
It depends on your app. Cast is used because usually we would like put and delete to be async calls (we don’t care about the response so we don’t want to wait for it). The Mnesia transaction is only there to ensure that the whole cluster is updated.
I use :timer.sleep(100) the few places where I do test the async calls. Otherwise I mock the cache module with sync calls. If this is only an issue with testing, and not a problem for production run then I would do either of the above.
Very cool! It sounds exactly what I try to solve with Add fingerprint to sessions · Issue #282 · pow-auth/pow · GitHub
If you have any feedback or code suggestions please do let me know on GH
My plan with the fingerprinting is to eventually get to complete session management and lifecycle tracking: Suggestion: Activity log and session management extension · Issue #122 · pow-auth/pow · GitHub
OvermindDL1
mnesia definitely is atomic in its operations unless using the dirty functions or multiple functions outside a transaction. Do you have a minimal reproducable example we can copy/paste into IEx that shows otherwise? If so that would be a major OTP bug.
OvermindDL1
Ah code! ^.^
Hmm, where you call delete_session(id), that’s the part that isn’t working? Where is it defined, it doesn’t seem to be on Pow’s github?
jsm
Thanks for the info! I’ve decided that using the pow methods is fine, the timeout for a few tests is acceptable and I don’t want to mess up the invalidators-code in MnesiaCache.
As I promised in another thread, I am planning to contribute back
I’ll be working on the implementation for another week or two (we want to get it right, test the code properly and use it for multiple projects), and during this time I hope to open a PR or contribute a guide or something along those lines.







