Thanks for replys everyone.
@mindok
but piping all activity through a single process (particularly one that “owns” the ets table) will end in tears before too long - one bad input will crash all games for all users.
Yes that’s exactly what I am worried about, the Genserver might become a bottleneck very fast, regarding the genserver crashing I think that won’t be a problem since it’s under a supervisor which would restart it and the genserver has no state everything is in ETS so we should be fine.
@LostKobrakai
Yea, currently the genserver crates the ETS table in its init
callback like :ets.new(@table_name, [:named_table, :set, :private])
.
This means only the genserver process is allowed to access the ETS table as its private. This will have to change if I have a genserver per game.
Making all ETS table access through the genserver will sequence writes as you mentioned, but I am not sure if there will be problems if the ETS table is public and accessed by multiple genservers(each game has its own genserver). Each genserver should access only its own game and not the data for some other game, so I think there shoudl be any problems.
@mattbaker
it’s unclear to me what the advantage is of reads and writes going through the genserver process in their example.
Yes that is exactly my thinking as well, the only use of genserver here is I think if we want to make the ETS table only acessible via the genserver process and also starting the genserver will create the table.(but I think just for creating the table we don’t need a genserver).
I am sharing the game genserver code that I have written for reference, there will be many more handle_call
added as I make the game
The below genserver is supervised by a Pictionary.StoreSupervisor
so it will restart if it crashes for some reason.
defmodule Pictionary.Stores.GameStore do
use GenServer
alias Pictionary.Game
require Logger
@table_name :game_table
@custom_word_limit 10000
@permitted_update_params [
"id",
"rounds",
"time",
"max_players",
"custom_words",
"custom_words_probability",
"public_game",
"vote_kick_enabled"
]
## Public API
def get_game(game_id) do
GenServer.call(__MODULE__, {:get, game_id})
end
def add_game(game) do
GenServer.call(__MODULE__, {:set, game})
end
def update_game(game_params) do
GenServer.call(__MODULE__, {:update, game_params})
end
def change_admin(game_id, admin_id) do
GenServer.call(__MODULE__, {:update_admin, %{game_id: game_id, admin_id: admin_id}})
end
def add_player(game_id, player_id) do
GenServer.call(__MODULE__, {:add_player, game_id, player_id})
end
def remove_player(game_id, player_id) do
GenServer.call(__MODULE__, {:remove_player, game_id, player_id})
end
## GenServer callbacks
def start_link(_opts) do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
end
def init(_args) do
# Create a ETS table
# private access ensure read/write limited to owner process.
:ets.new(@table_name, [:named_table, :set, :private])
{:ok, nil}
end
def handle_call({:get, game_id}, _from, state) do
{:reply, fetch_game(game_id), state}
end
def handle_call({:set, %Game{id: game_id}} = game_data, _from, state) do
# Below pattern match ensure genserver faliure and restart in case
# of ETS insertion faliure
true = :ets.insert(@table_name, {game_id, game_data})
Logger.info("Create game #{game_id}")
{:reply, game_data, state}
end
def handle_call({:update, %{"id" => id} = game_params}, _from, state) do
# For some reason :ets is returning two types of values, this case block handles both
game = fetch_game(id)
updated_game =
if game do
filtered_params =
game_params
|> Enum.filter(fn {key, _val} -> Enum.find(@permitted_update_params, &(&1 == key)) end)
|> Enum.map(fn {key, val} -> {String.to_atom(key), val} end)
|> Enum.into(%{})
|> handle_custom_words()
updated_game = struct(game, Map.put(filtered_params, :updated_at, DateTime.utc_now()))
true = :ets.insert(@table_name, {id, updated_game})
Logger.info("Update game #{id}")
updated_game
end
{:reply, updated_game || game, state}
end
def handle_call({:update_admin, %{game_id: id, admin_id: admin_id}}, _from, state) do
game = fetch_game(id)
game.players
|> Enum.find(&(&1 == admin_id))
|> if do
updated_game = struct(game, %{creator_id: admin_id, updated_at: DateTime.utc_now()})
true = :ets.insert(@table_name, {id, updated_game})
Logger.info("Change admin for game #{id} to #{admin_id}")
{:reply, updated_game, state}
else
Logger.warn("Could not change game admin")
{:reply, game, state}
end
end
def handle_call({:add_player, game_id, player_id}, _from, state) do
game = fetch_game(game_id)
if game && MapSet.size(game.players) <= game.max_players do
game = %Pictionary.Game{game | players: MapSet.put(game.players, player_id)}
true = :ets.insert(@table_name, {game_id, game})
Logger.info("Add player #{player_id} to game #{game_id}")
{:reply, game, state}
else
Logger.warn("Could not add player to game")
{:reply, :error, state}
end
end
def handle_call({:remove_player, game_id, player_id}, _from, state) do
game = fetch_game(game_id)
if game do
game = %Pictionary.Game{game | players: MapSet.delete(game.players, player_id)}
true = :ets.insert(@table_name, {game_id, game})
Logger.info("Removed player #{player_id} from game #{game_id}")
# Remove game if everyone leaves
if MapSet.size(game.players) == 0 do
true = :ets.delete(@table_name, game.id)
Logger.info("Removed game #{game_id}")
end
# Change admin if admin leaves
if MapSet.size(game.players) > 0 && player_id == game.creator_id do
Task.start_link(fn ->
new_admin = get_random_player(game.players)
change_admin(game_id, new_admin)
# Broadcast on game channel about admin change
PictionaryWeb.Endpoint.broadcast!("game:#{game.id}", "game_admin_updated", %{
creator_id: new_admin
})
end)
end
{:reply, game, state}
else
Logger.warn("Could not remove player from game")
{:reply, :error, state}
end
end
## Private helpers
defp handle_custom_words(%{custom_words: custom_words} = filtered_params) do
custom_word_list =
custom_words
|> String.split(",")
|> Stream.map(fn word ->
word
|> String.downcase()
|> String.trim()
end)
|> Stream.filter(&(String.length(&1) < 30 || String.length(&1) > 2))
|> Stream.uniq()
|> Enum.take(@custom_word_limit)
Map.put(filtered_params, :custom_words, custom_word_list)
end
defp handle_custom_words(filtered_params), do: filtered_params
defp fetch_game(game_id) do
case :ets.lookup(@table_name, game_id) do
[{_id, {:set, game}}] -> game
[{_game_id, game}] -> game
_ -> nil
end
end
defp get_random_player(players) do
players
|> MapSet.to_list()
|> Enum.shuffle()
|> List.first()
end
end