Design question - modelling friendships

Hello! First post on my first serious Elixir project.

I’m modelling a friendship as an M:N relationship between two Members.
Currently, my Friendship schema has a composite primary key and associated unique index of member and friend ids, and the schema also holds the associated Invitation id.

I want to block the creation of a Friendship if there is an existing entity with the roles of member and friend reversed.

What are your recommendations for handling this requirement? Two indexes? Constraints? Keeping in mind avoiding a race condition.

Thanks in advance.

1 Like

Just curious, what is the probability of having a race condition?

Probably very low! But this is a passion project, so I can afford to stickle.

I’ve come up with a solution which passes limited testing. I’d be interested in your opinion, or alternative solutions to the requirement.

  defp insert_friendship(member_id, friend_id) do
    attrs = %{member_id: member_id, friend_id: friend_id}
    reverse_attrs = %{member_id: friend_id, friend_id: member_id}
    attrs_error = {:error, "Duplicate: #{inspect(attrs)}"}
    reverse_attrs_error = {:error, "Duplicate: #{inspect(reverse_attrs)}"}

    case Repo.transaction(fn ->
           case Repo.get_by(Friendship, reverse_attrs) == nil &&
                  Repo.insert(Friendship.changeset(%Friendship{}, attrs)) do
             false -> reverse_attrs_error
             {:ok, friendship} -> {:ok, friendship}
             {:error, _changeset} -> attrs_error
           end
         end) do
      {:ok, {:ok, friendship}} -> {:ok, friendship}
      {:ok, {:error, _}} -> reverse_attrs_error
      {:error, :rollback} -> attrs_error
    end
  end

Why not just insert them both?

2 Likes

You have to be extremely careful writing transactions like this when using a database that does not provide strong consistency guarantees out of the box, which is basically all of them. In e.g. Postgres this function is still vulnerable to race conditions unless you are running your transactions under SERIALIZABLE, which is not the default (and is often avoided for performance).

There is no guarantee your read is valid at commit time. Even under snapshot you would still get write skew here (it’s practically a textbook example). You should abandon this approach.

You can create a table with primary key (user_id, friend_id) (or a unique constraint) and then insert both sides of the friendship within one transaction. The unique constraint will save you from the race conditions.

This approach also has a substantial benefit: since you store both sides of the friendship, you can grab a user’s friends with a single index scan, which is most certainly what you want for performance. If you only store one side you would have to scan the entire table.

4 Likes

As we learned in era the Social Media, friendship is actually one-way. I could friend/unfriend you independent from what you think of me. If you want to open the Pandora’s box for mutual friendship, there will be more layers:

  • Do I think of you as a friend
  • Do you think of me as a friend
  • Do I know that you think of me as a friend
  • Do you know that I think of you as a friend
  • Do I know that you know that I think of you as a friend
  • ad infinitum …

:grinning: I was thinking of friendship as used “IRL” rather than the co-opted and weaponised concept from social media. I can’t imagine any healthy outcomes for one-sided friendships! That’s why I’ve opted for invitations to all relationships. And deleting from either side dissolves the union.

1 Like

Thanks @Hermanverschooten and @garrison. I’ve had an enjoyable(?) session on Wikipedia looking at serializability and snapshot isolation! The transaction-wrapped double insertion is a better solution, with the search benefits you’ve mentioned.

3 Likes

I have modelled friendships by having 2 rows for a single friendship and it worked well.

The app currently has 300k rows in the table so 150k friendships and I haven’t had any issues.

2 Likes

Here is my final code:

  defp insert_friendship(member_id, friend_id) do
    changeset =
      Friendship.changeset(
        %Friendship{},
        %{member_id: member_id, friend_id: friend_id}
      )

    reverse_changeset =
      Friendship.changeset(
        %Friendship{},
        %{member_id: friend_id, friend_id: member_id}
      )

    case Repo.transaction(fn ->
           case Repo.insert(changeset) do
             {:ok, _friendship} -> Repo.insert(reverse_changeset)
             other -> other
           end
         end) do
      {:ok, {:ok, friendship}} -> {:ok, friendship}
      # Value of response is typically :rollback
      {:error, response} -> {:error, response}
    end
  end

1 Like

That looks much better. A couple things I would point out:

First, you could insert both rows in one round-trip with Repo.insert_all. Up to you whether that’s worth it, though.

Second, you can actually pipe the result into case, which I personally find more readable. Like:

Repo.transaction(fn ->
  Repo.insert(...)
)
|> case do
  {:ok, _} -> :ok
end

I once worked on a project that has this kind of “Friendship” relation and our solution to this was to “sort” the ids of the members when inserting it. So the steps for this were:

  1. Given a Friendship schema, where it has the first_user_id and second_user_id fields
  2. Create a unique composite index on [:first_user_id, :second_user_id]
  3. Before inserting the relationship in the database, guarantee that first_user_id < second_user_id:
    {first, last} = 
      cond do 
        first_user_id == second_user_id, do: raise "error" # or return {:error, "..."} and {:ok, _} tuples
        first_user_id < second_user_id -> {first_user_id, second_user_id}
        first_user_id > second_user_id -> {second_user_id, first_user_id}, 
     end
    
  4. Now, when inserting, the unique constraint will take care of validating it

Hope this also helps :slight_smile:

2 Likes

If you are doing it like that then you might as well just use Ecto.Multi.

This may not be the answer you are looking for, but it’s somewhat on topic as it involves handling Friendships/Requests in another way.

If you create a Requests, Friends and User_Relations table. you can store multiple states between two users in one location for each user and keep the Requests and Friends as simple joins.

As long as you run the transactions through Multi, you should be able to keep the User_Relations in sync with the actual Request/Friend records.

User_Relations table.

defmodule Phxie.Schema.User_Relation do
  use Ecto.Schema
  import Ecto.Changeset
  
  alias Phxie.Schema.{Board, User} 

  schema "user_relations" do
    belongs_to :user,         User
    belongs_to :current_user, User, foreign_key: :current_user_id

    field :blocking,     :boolean
    field :blocked,      :boolean
    field :following,    :boolean
    field :friends,      :boolean
    field :sent_request, :boolean
    field :rec_request,  :boolean

    timestamps()
  end

  @id    [:current_user_id, :user_id]
  @state [:blocking, :blocked, :following, :friends, :sent_request, :rec_request]

  @doc false
  def changeset(follow, attrs) do
    follow
    |> cast(attrs, @id ++ @state)
  end
end

Button example that will display different text based on whether users are friends, blocking one another, sent a request or received one. If no relation exists the button displays add friend.

   <.friend_btn    
      {@menu_btn} 
      type={:user}    
      mods="br-0"          
      active_class="btn-s"
      active={@post_relation.friends}
      name={@post["user_info"]["username"]}
      disabled_if={@post_relation.blocked}
      text={friend_relation_text(@post_relation)} 
    />

  def friend_relation_text(user_relations) do
    cond do
      user_relations.blocked      -> "Blocked"
      user_relations.blocking     -> "Blocked"
      user_relations.sent_request -> "Cancel Request"
      user_relations.rec_request  -> "Decline Request"
      user_relations.friends      -> "Remove Friend"
      true                        -> "Add Friend"
    end
  end

Requests example

 defmodule Phxie.Requests do
  import Ecto.Query, warn: false
  
  import Phxie.{EctoHelpers, GetHelpers}
  import PhxieWeb.AuthHelpers

  alias Ecto.Multi
  alias Phxie.{Repo, Blocks, Friends, User_Relations}
  alias Phxie.Schema.{Block, Friend, Notification, Notification_Setting, Request, User, User_Setting, User_Relation}

  @friend_request "Friend Request"

##########################
####  Friend Request  ####
##########################

  #######################################
  ####  Friend Request : Auth Check  ####
  #######################################

  def handle_friend_request(params, socket) do
    user         = get_user!(params["name"])
    current_user = socket.assigns.current_user

    with :ok <- check_authentication("user", current_user, user) do       
        action_select(current_user, user)
      else
        error -> error
    end
  end

  ##########################################
  ####  Friend Request : Action Select  ####
  ##########################################
  
  defp action_select(current_user, user) do
    user_relations  = User_Relations.get_user_relations("user", current_user, user)

    case user_relations do
      nil                                -> create_friend_request_and_relations(current_user.id, user.id)
      %User_Relation{blocked: true}      -> {:error, :blocked_by_user, ["You are blocked by #{user.username}."]}
      %User_Relation{blocking: true}     -> {:error, :blocking_user,   ["You are blocking #{user.username}."]}
      %User_Relation{sent_request: true} -> delete_friend_request(current_user.id, user.id)
      %User_Relation{rec_request: true}  -> delete_friend_request(user.id, current_user.id)
      %User_Relation{friends: true}      -> nil
      _                                  -> create_friend_request(current_user.id, user.id)

    end
  end

  ################################################
  ####  Friend Request : Create and Relation  ####
  ################################################

  defp create_friend_request_and_relations(current_user_id, user_id) do   
    settings = ecto_get(Notification_Setting, user_id, [:user])

    if settings.friend_request do
      Multi.new()
      |> Multi.insert(:request, %Request{current_user_id: current_user_id, user_id: user_id, type: @friend_request})
      |> Multi.insert(:user_relations_1, %User_Relation{current_user_id: current_user_id, user_id: user_id, sent_request: true})
      |> Multi.insert(:user_relations_2, %User_Relation{current_user_id: user_id, user_id: current_user_id, rec_request:  true})
      |> Repo.transaction()
    
      {:ok, :request_created}
    else
      {:error, :requests_are_disabled, ["#{settings.user.username} has friend requests disabled."]}
    end
  end

  ###################################
  ####  Friend Request : Create  ####
  ###################################

  defp create_friend_request(current_user_id, user_id) do   
    settings = ecto_get(Notification_Setting, user_id, [:user])

    if settings.friend_request do
      Multi.new()
      |> Multi.insert(:request, %Request{current_user_id: current_user_id, user_id: user_id, type: @friend_request})
      |> Multi.update_all(:user_relations_1, from(r in User_Relation, where: r.current_user_id == ^current_user_id and r.user_id == ^user_id), set: [sent_request: true])
      |> Multi.update_all(:user_relations_2, from(r in User_Relation, where: r.current_user_id == ^user_id and r.user_id == ^current_user_id), set: [rec_request:  true])
      |> Repo.transaction()
    
      {:ok, :request_created}
    else
      {:error, :requests_are_disabled, ["#{settings.user.username} has friend requests disabled."]}
    end
  end

  ###################################
  ####  Friend Request : Delete  ####
  ###################################

  def delete_friend_request(current_user_id, user_id) do
    request = ecto_get_by(Request, %{current_user_id: current_user_id, user_id: user_id, type: @friend_request})

    try do
      Multi.new()
      |> Multi.delete(:delete, request)
      |> Multi.update_all(:user_relations_1, from(r in User_Relation, where: r.current_user_id == ^current_user_id and r.user_id == ^user_id), set: [sent_request: false])
      |> Multi.update_all(:user_relations_2, from(r in User_Relation, where: r.current_user_id == ^user_id and r.user_id == ^current_user_id), set: [rec_request:  false])
      |> Repo.transaction()

        {:ok, :request_deleted}
    rescue
      Ecto.StaleEntryError ->
        {:error, :stale_entry, ["Could not delete request, the request may have already been accepted or deleted."]}
    end
  end
end

Probably bikeshedding here, but it seems like as a community we may be heading away from Ecto.Multi. Personally I’ve been avoiding it for a while because it just started to bug me - I have yet to fully reason through why, but clearly I am not alone. I wish I had heard of the approach described in that PR sooner, as I find it very appealing.

But also, in this particular case it would be nicer to one-shot the inserts with an insert_all as I mentioned.

1 Like

I agree, since the new transact functionality can handle everything that Ecto.Multi can, with the addition of much more readable code (as you don’t have to wrap all your operations in a Ecto.Multi struct), there is no more incentive to use multis.

It’s a little bit strange that to be fair that Ecto.Multi was not soft deprecated at this point, as I am positive that transact functionality contains all the features from multis.

1 Like

I’m apparently the only person around here who doesn’t see the claimed clear wins of Repo.transact over Ecto.Multi though in all honesty the discussion was quite a while ago and the details are almost gone from my memory.

1 Like

If I’m not mistaken that is basically 1:1 to how it works in rails, you get magically rollbacks should anything go wrong with your ecto operations.

Sure, and that’s different than Repo.transaction that we have since ages ago, how exactly?.. :thinking:

But yeah, I’ll have to go read up on it again. I really don’t remember almost anything from that discission. And now there’s also a GitHub issue about it as well.