Distributed systems are hard
I want to invite you to test our system in a particular way. I assume you have two users on your platform: Lubien and Enemy. Open one browser window logged in as Lubien and another browser window logged in as enemy (you should probably use incognito), on each account open the user page for the other user. As Lubien, I can see Enemy's points at 72 and mine at 104. From the other point of view, Enemy sees my points at 104 and their navbar tells they have 72.
Now I'm going to concede 10 losses to Enemy using my UI.
Without refreshing, I'm going to Enemy's browser and declare a draw match.
You probably already guessed but if you refresh my window it's going to downgrade Enemy's points to 73 too. All that mess happens because we trust the current LiveView state to apply updates to points and both users happened to be with their windows open. There are many solutions to this: sync LiveViews when points change, create a CRDT, use a Postgres table to record matches then always sum the results of points, etc. The last option seems really good too, but I'd like to use this bug as an excuse to show off some more Ecto so bear with me.
Removing business logic from LiveView
LiveView has no guilt in this bug but it definitely shouldn't be the one doing these calculations. We should put business logic inside our context modules not inside handle_event/3
. Let's start by fixing that. Edit show.ex
:
def handle_event("concede_loss", _value, %{assigns: %{user: user}} = socket) do
- {:ok, updated_user} = Accounts.update_user_points(user, user.points + 3)
+ {:ok, updated_user} = Accounts.concede_loss_to(user)
{:noreply, assign(socket, :user, updated_user)}
end
def handle_event("concede_draw", _value, %{assigns: %{current_user: current_user, user: user}} = socket) do
- {:ok, updated_user} = Accounts.update_user_points(user, user.points + 1)
- {:ok, updated_my_user} = Accounts.update_user_points(current_user, current_user.points + 1)
+ {:ok, updated_my_user, updated_user} = Accounts.declare_draw_match(current_user, user)
{:noreply,
socket
|> assign(:user, updated_user)
|> assign(:current_user, updated_my_user)
}
end
Then head back to accounts.ex
to create those functions:
@doc """
Adds 3 points to the winning user
## Examples
iex> concede_loss_to(%User{points: 0})
{:ok, %User{points: 3}}
"""
def concede_loss_to(winner) do
update_user_points(winner, winner.points + 3)
end
@doc """
Adds 1 point to each user
## Examples
iex> declare_draw_match(%User{points: 0}, %User{points: 0})
{:ok, %User{points: 1}, %User{points: 1}}
"""
def declare_draw_match(user_a, user_b) do
{:ok, updated_user_a} = update_user_points(user_a, user_a.points + 1)
{:ok, updated_user_b} = update_user_points(user_b, user_b.points + 1)
{:ok, updated_user_a, updated_user_b}
end
You should be able to easily run mix test
to verify everything still works just fine. Now what we need is to avoid assuming the current amount of points is the most updated one. Instead of update_user_points
we must create an atomic function that increments points based on the current database state:
@doc """
Adds 3 points to the winning user
## Examples
iex> concede_loss_to(%User{points: 0})
{:ok, %User{points: 3}}
"""
def concede_loss_to(winner) do
increment_user_points(winner, 3)
end
@doc """
Adds 1 point to each user
## Examples
iex> declare_draw_match(%User{points: 0}, %User{points: 0})
{:ok, %User{points: 1}, %User{points: 1}}
"""
def declare_draw_match(user_a, user_b) do
{:ok, updated_user_a} = increment_user_points(user_a, 1)
{:ok, updated_user_b} = increment_user_points(user_b, 1)
{:ok, updated_user_a, updated_user_b}
end
@doc """
Increments `amount` points to the user and returns its updated model
## Examples
iex> increment_user_points(%User{points: 0}, 1)
{:ok, %User{points: 1}}
"""
defp increment_user_points(user, amount) do
{1, nil} =
User
|> where(id: ^user.id)
|> Repo.update_all(inc: [points: amount])
{:ok, get_user!(user.id)}
end
We created increment_user_points/2
that takes the user and the number of points. The real magic here comes from Repo.update_all/3. We define a query and then pass it to Repo.update_all/3
to run. The query is pretty simple:
- On line 30 we start by saying this query affects all users because we started with the User Ecto model.
- At line 31 we scope this query only for users with an id equal to
user.id
using where/3. We need to use the pin operator (^
) to put a variable that comes from outside the query in there, it will escape inputs to prevent SQL injection. - Last but not least, we run
Repo.update_all/3
using the specialinc
option to increment points byamount
as many times.
Since this query returns a tuple containing the count of updated entries and nil
unless we manually select fields, we just ignore the result and a {:ok, get_user!(user.id)}
to get the updated user. Your bug should be fixed now.
Let's not forget our tests
The good thing is that since these functions are already being used on our LiveView we know they work by simply running mix test
. But let's not forget to test those out on our accounts_test.exs
too. Who knows, maybe that LiveView page goes away and we lose that coverage.
describe "concede_loss_to/1" do
test "adds 3 points to the winner" do
user = user_fixture()
assert user.points == 0
assert {:ok, %User{points: 3}} = Accounts.concede_loss_to(user)
end
end
describe "declare_draw_match/2" do
test "adds 1 point to each user" do
user_a = user_fixture()
user_b = user_fixture()
assert user_a.points == 0
assert user_b.points == 0
assert {:ok, %User{points: 1}, %User{points: 1}} = Accounts.declare_draw_match(user_a, user_b)
end
end
describe "increment_user_points/2" do
test "performs an atomic increment on a single user points amount" do
user = user_fixture()
assert user.points == 0
assert {:ok, %User{points: 10}} = Accounts.increment_user_points(user, 10)
assert {:ok, %User{points: 5}} = Accounts.update_user_points(user, 5)
assert {:ok, %User{points: 15}} = Accounts.increment_user_points(user, 10)
end
end
The first two ones are pretty obvious but the increment_user_points/2
suite tries to reproduce the bug the out-of-sync bug we caught at the start of this post and ensures that this function solves it.
Summary
- Be careful doing updates on stated based on cached state, they can easily go out of sync.
- Business logic should live outside LiveView.
- Ecto allows you to run atomic update queries with Repo.update_all/3.
Chapter 9: TODO đ
Top comments (2)
JoĂŁo,
I want to congratulate you for the great article, with each step well detailed and the code improvements, very well explained.
I'm a beginner in both Elixir and Phoenix and this material on LiveView is fantastic, as it conveys the use of the tool in a simple way.
As instructed, I accessed a browser with one user and in the other browser, an incognito tab with another user. Much of what was taught I was able to apply. I believe that I must have left something out, because when I give the 3 points to a user, in the other browser, the points are not updated, but I will download the source that you made available and carry out the necessary maintenance, so that the two browsers present the same amount of points.
Big blessings and greetings from Manaus.
Im glad you're liking it!
Actually we are not on the Live updates bit, we will look into that possibly after I cover a basic CRUD. You'll be surprised how easy it is to setup these kinds of things with LiveView
Greetings from Belém!