I ended up on this thread because something didn’t feel right about this decision. I didn’t know how to kill it aside from using a cron
job - I need these carts to expire. So naturally I’m thinking Redis or Mnesia - but carts are kind of transient. I’m not interested in the Cart
itself, and I’m only interested short-term in the “state” of the cart. A cart will either be used to execute an order in the near-term, or it won’t.
The genserver behaviour has a timeout option which can be used to terminate the process after some idle time (no messages). Together with a dynamic supervisor and a registry it makes ephemeral processes possible.
defmodule Cart.Application do
use Application
def start(_type, _args) do
children =
[
{Registry, keys: :unique, name: Cart.Registry},
Cart.Supervisor
]
opts = [strategy: :one_for_one, name: __MODULE__.Supervisor]
Supervisor.start_link(children, opts)
end
end
defmodule Cart.Supervisor do
use DynamicSupervisor
def start_link(opts) do
DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
DynamicSupervisor.init(strategy: :one_for_one)
end
@spec start_cart(cart_id :: pos_integer) :: DynamicSupervisor.on_start_child()
@spec start_cart(cart_id :: pos_integer, opts :: Keyword.t()) :: DynamicSupervisor.on_start_child()
def start_cart(cart_id, opts \\ []) when is_integer(cart_id) do
DynamicSupervisor.start_child(__MODULE__, {Cart, [{:cart_id, cart_id} | opts]})
end
@spec stop_cart(cart_id :: pos_integer) :: :ok | {:error, :not_found}
def stop_cart(cart_id) when is_integer(cart_id) do
case Registry.lookup(Cart.Registry, cart_id) do
[{pid, _}] -> DynamicSupervisor.terminate_child(__MODULE__, pid)
[] -> {:error, :not_found}
end
end
end
defmodule Cart do
use GenServer, restart: :transient
require Record
Record.defrecordp(:state, [:timeout]) # I usually keep a bit more data here ;)
@timeout 10 * 60 * 1000 # exit after 10 minutes of inactivity
def start_link(opts) do
cart_id = opts[:cart_id] || raise("need :cart_id")
GenServer.start_link(__MODULE__, opts, name: via(cart_id))
end
def add(cart_id, item) do
call(cart_id, {:add, item})
end
@doc false
def via(cart_id) when is_integer(cart_id) do
{:via, Registry, {Cart.Registry, cart_id}}
end
defp call(cart_id, message) when is_integer(cart_id) do
GenServer.call(via(cart_id), message)
catch
:exit, {:noproc, _} -> # a bit of a hack, but it works
_ = Cart.Supervisor.start_cart(cart_id)
call(cart_id, message)
end
# ^^^ make sure the process can always be started
# otherwise you might get stack overflow (try/catch is not tail recursive)
# if there is a possibility of a faulty process, add an attempt counter
# call(cart_id, message, attempts_left - 1)
@doc false
def init(opts) do
send(self(), :init)
{:ok, state(timeout: opts[:timeout] || @timeout)} # custom timeouts for tests
end
@doc false
def handle_info(:init, state(timeout: timeout) = state) do
# init process state
# previous state can be read from a database
{:noreply, state, timeout}
end
def handle_info(:timeout, state) do
# the process has been idle for 10 minutes, time to die
# the current state can be persisted
{:stop, :normal, state}
end
@doc false
def handle_call({:add, item}, _from, state(timeout: timeout) = state) do
# add item to the cart, maybe persist it in the database as well
{:reply, :ok, state, timeout}
end
end
Usage:
Cart.add(123, %Cart.Item{...}) # will start a cart process if it doesn't yet exist
Cart.add(123, %Cart.Item{...}) # uses the same process, will exit after 10 min of inactivity
It basically works as a very simplified version of orleans. But also inherits one of its state-managing benefits that most caches can’t provide – there are no stale entries / data races since the only way to update the cart is through interacting with a cart process. This approach also works across nodes.
This is where the functional break happened in my mind: I’m much more interested in the data produced than the cart’s behavior . Logs can tell me much more than the current state of the cart and an arbitrary status flag.
It’s possible to persist each event in either the call
function or in each of handle_call
s. In chat bots where I mostly use this approach (for user sessions) I persist almost everything (but in sqlite, each process (user) gets its own database), so that I can replay the events in case of a failure / faulty migration.
Moving to ephemeral gen(servers | statems) from ets tables made the code much clearer as well (for me, at least). The message handlers now just call the processes and render the results. Functional core, imperative shell, and all that.