When to use `handle_continue`?

So in OTP 21, a new handle_continue callback was introduced to the gen_server module.

From the Erlang documentation:

I fully understand why it is useful to use it during initialization, where it will replace the pattern of the process sending itself a message from the init callback.

What I would like to understand, is why/when handle_continue is useful in other situations. Since handle_continue is immediately called after the former callback returned, it does not make the gen_server more available to other processes.

The one thing that handle_continue does that a plain function call does not, is update the process’ state in the meantime. Does handle_continue then have to do with an improvement to introspection somehow?

3 Likes

If I understood it correctly, a process doesn’t start until init is done, so it is not able to accept messages into its inbox. If you move that logic to handle_continue it is possible to at least send messages to the process, and as soon as it is done with handle_continue it’ll be able to start working on those messages.

So in that sense it is more available. At least it can accept messages.

Another consideration is that init blocks the process that starts the process, eg a Supervisor. As soon as init finishes it can move on with other stuff, letting the newly started process sort out any other process initialization itself. At the same time, handle_continue guarantees that no other messages are processed before that one.

7 Likes

I think the splitting the work in a callback in multiple steps is also useful. E.g. if a genserver fails you’ll get the last message it worked on, which for continue parts is {:continue, value}. So you get improved errors for cases, where a certain msg/call/cast doesn’t only start “one thing”, but actually multiple discrete steps.

1 Like

Hey @jola, unfortunately this is partially incorrect. When you’re in init, the process has definitely “started” in the sense that it has a pid, and anything with a pid can definitely receive messages. You’re correct that it hasn’t “started” in the OTP sense however, and this hints at the need for handle_continue. It’s precisely because a process can receive messages during init that handle_continue is important. The old trick of doing send(self(), :continue) wasn’t guaranteed to be the first message in the inbox, but handle_continue is guaranteed to accomplish it’s work immediately after init, before other messages.

9 Likes

Ah, so it’s mainly a question of not blocking the Supervisor (or whatever else you have starting your process).

It’s precisely because a process can receive messages during init that handle_continue is important.

I mean, kinda, but if it was only a question of avoiding other messages getting before your initialization in queue you’d just use init. So you want to avoid blocking the starting process, but without letting other messages get handled before initialization is done, and that’s why handle_continue is an improvement over sending a message to yourself.

2 Likes

I think that you’re understanding the usage of handle_continue well, and I would expect that 95% of the time it will be used as a response in init. I would expect that handle_continue support was added to the other callbacks because most of them are already consistent in that way and that sometimes there will be cause to use it from a different callback.

One somewhat contrived example is if you have a GenServer that is used like a cache and if the data is stale you want to return no more than one stale response you can use handle_continue to avoid having to do a manual GenServer.reply/2:

  def handle_call(:fetch_widget_counts, from, state) do
    if stale?(state.last_fetched_at) do
      GenServer.reply(from, state.widget_counts)

      widget_counts = fetch_widget_counts()
      state = %{state | last_fetched_at: NaiveDateTime.utc_now(), widget_counts: widget_counts}
      {:noreply, state}
    else
      {:reply, state.widget_counts, state}
    end
  end

vs the code possibly being more readable with handle_continue (and I find it nice to show it as two distinct steps, especially if you wanted to use this specific continue multiple times):

  def handle_call(:fetch_widget_counts, _from, state) do
    if stale?(state.last_fetched_at) do
      {:reply, state.widget_counts, state, {:continue, :update_widget_counts}}
    else
      {:reply, state.widget_counts, state}
    end
  end

  def handle_continue(:update_widget_counts, state) do
    widget_counts = fetch_widget_counts()
    state = %{state | last_fetched_at: NaiveDateTime.utc_now(), widget_counts: widget_counts}
    {:noreply, state}
  end

With that said this is a rather contrived example (and most likely isn’t a good use of a GenServer). But my answer is consistency (similar to how you still have to respond with `:noreply even if there’s not chance of replying) and a possibility for use/clarity improvements.

8 Likes