DEV Community

Lubien
Lubien

Posted on • Edited on

The Lazy Programmer's Intro to LiveView: Chapter 8

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.

Lubien POV: Enemy has 72 points and his navbar says Lubien has 104.

Enemy POV: Lubien has 104 points and their navbar has 72.

Now I'm going to concede 10 losses to Enemy using my UI.

Lubien POV: Enemy has 102 points and his navbar still shows that he has 104 points.

Without refreshing, I'm going to Enemy's browser and declare a draw match.

Enemy POV: Lubien has 105 points and their navbar says they have 73 points.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 special inc option to increment points by amount 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
atendendovocetecnologia profile image
AtendendoVocĂȘ Tecnologia • Edited

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.

Collapse
 
lubien profile image
Lubien

Im glad you're liking it!

because when I give the 3 points to a user, in the other browser, the points are not updated

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!