The Lazy Programmer's Intro to LiveView: Chapter 8

lubien

Lubien

Posted on June 18, 2023

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 😉

💖 💪 🙅 🚩
lubien
Lubien

Posted on June 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related