Modifying the state of GenServer when I have a spawn in "handle_cast"

Suppose, I have this in my GenServer which I’ve been suggested to used:

def handle_call({:do_work, from, status}) do
  spawn(fn ->
    res = do_some_long_work()
    # what to do with res?
  end)

  {:noreply, state}
end

It always returns the same state to the GenServer. Immediately. Therefore, how can I remove items from “state” when a time comes?

That is, after I’ve received an answer from do_some_long_work() and depending on what it is, how can I modify the ‘state’ of my GenServer so that it removes one/many item(s) from it?

By “a time comes” I mean that “res” contains a special flag set to true. Therefore, it’s not always set to true.

Just call or cast a message to your server (depending on the guarantees you need) and add a corresponding clause which alters your state accordingly.

2 Likes

You can cast to your GenServer from inside the spawned process.

def handle_cast(:do_work, state) do
  pid = self()

  spawn(fn ->
    res = do_some_long_work()
    GenServer.cast(pid, {:finished_work, res})
  end)

  {:noreply, state}
end

def handle_cast({:finished_work, res}, state) do
  # new_state = ...
  {:no_reply, new_state}
end

https://hexdocs.pm/elixir/GenServer.html

3 Likes

GenServer.reply(...) instead of GenServer.cast(pid, {:finished_work, res}) won’t work?

Two different things.

reply sends an answer to your original caller while cast/call will send messages to your server. You have to call reply to unblock the original caller before leaving the spawned process, or your original caller will wait forever (unless you called with a timeout).

Also check out the Task module, https://hexdocs.pm/elixir/Task.html

how - check?

He did that before, get caught in trouble and was suggested to use bare processes.

Oh really? Because after reading this thread I had a interesting thought.

I’m going to assume status is a map. If it’s not then I’m just going to pretend it is anyway.

# Prevent 2 tasks at once
def handle_call(:do_work, _, %{task: _} = status) do
  {:reply, {:error, :already_working}, status}
end

def handle_call(:do_work, from, status) do
  %{ref: ref} = Task.async(fn ->
    do_some_long_work()
  end)

  {:noreply,  Map.put(status, :task, {ref, from})}
end

def handle_info({ref, result}, %{task: {task_ref, from}} = status) when ref == task_ref do
  Process.demonitor(ref, [:flush]) # Remove the Task Monitor
  ... # Do other stuff
  GenServer.reply(from, res) # Reply to the original call
  {:noreply, Map.delete(status, :task)}
end

Though your GenServer.call/3 could easily time out if you didn’t use :inifinity or if you don’t have guarantees that the task will either finish or fail you could have a stalled Task.

If you wanted to add a timeout your could do it by tweaking the code code a little.

# Prevent 2 tasks at once
def handle_call({:do_work, _}, _, %{task: _} = status) do
  {:reply, {:error, :already_working}, status}
end

def handle_call({:do_work, timeout}, from, status) do
  task = Task.async(fn ->
    do_some_long_work()
  end)

  timer_ref = Process.send_after(self(), {:task_timeout, task}, timeout)

  {:noreply,  Map.put(status, :task, {task, from, timer_ref})}
end

def handle_info({ref, result}, %{task: {%Task{ref: task_ref},_, _}} = status)
when ref == task_ref do
  {task, from, timer_ref} = status.task
  Process.demonitor(ref, [:flush]) # Remove the Task Monitor
  cancel_timer(task, timer_ref)
  ... # Do other stuff
  GenServer.reply(from, res) # Reply to the original call
  {:noreply, Map.delete(status, :task)}
end

def handle_info({:task_timeout, task}, %{task: {stored, from, _}} = status) when task == stored do
  case Task.shutdown(task, :brutal_kill) do
    nil ->
      GenServer.reply(from, {:error, :timed_out})
      {:noreply, Map.delete(status, :task)
    res -> # We got a result before it was finished killing -_-
      handle_info({task.ref, res}, status)
  end
end

defp cancel_timer(%{ref: task_ref}, timer_ref) do
  # Race conditions are best conditions
  unless Process.cancel_timer(ref) do
    # The timer has gone off and the message was sent. Let's flush it.
    receive do
      {:timeout, ^task_ref} -> :noop
    after
      0 -> :noop # Who knows, but lets not wait
    end
  end
end

Sorry if there were typos or I missed anything, I didn’t really think implementing a timeout would be that much more code.

1 Like