Thomas101

Thomas101

GenServer blocking vs callbacks

On our server, we have some remote resources that we want to fetch and periodically update in the background. To update these resources we need to make http requests to third-party services that have rate limits. This means we want to lock down the number of concurrent requests and also pause requests when the third party sends a 429 response. We’ve had a few thoughts about how to do this, but so far each has its pitfalls.

On a high level, the layout of our system looks like this…

  • TheThing - stores info in the state about the resource that we can save to the database etc
  • NetworkResource - a single-threaded http request queue that when presented with a 429 waits and retries the request

When the TheThing decides to update, it needs to make multiple calls to NetworkResource to re-fetch and save the result back into the database. Depending on the local state and remote state, not all network calls may need to be fired, so for example if the timestamp of the first remote resource is the same as the local one, subsequent calls may not need to be made.

We’ve played around with a couple of different methods, but none feel ideal and it feels like we’re swimming against the flow a little bit. Some of it might be down to our C++ & JavaScript background where “async good, sync bad”. I’ve tried to cut the code examples down to the bare bones just so you get the gist…

Method 1: Use callbacks

In the first method, we’ve been using multiple GenServers and writing queues to manage the amount of requests. When we make a request to the NetworkResource we pass a function definition to callback on completion. This has the advantage that none of the processes become locked, but makes the code quite complex and spaghetti-ish, especially as it grows and you have multiple resource calls and callbacks.

defmodule Test.TheThing do
  use GenServer

  def update(id) do
    GenServer.call(__MODULE__, {:update})
  end

  def callback_fetch_resource(response) do
    GenServer.call(__MODULE__, {:callback_fetch_resource, response})
  end

  def handle_call({:update}, from, state) do
    next_state = %{ action: "wait_fetch_resource" }
    Test.NetworkResource.fetch(&callback_fetch_resource/1)
    {:reply, :ok, next_state}
  end

  def handle_call({:callback_fetch_resource, response}, from, state) do
    # Do some stuff, fire off the next request using the same callback pattern
    # ...
    next_state = %{ action: "wait_fetch_other_resource" }
    {:reply, :ok, next_state}
  end
end

defmodule Test.NetworkResource do
  def init(_) do
    Process.send_after(self(), :do_next, 1000)
    {:ok, %{ requests: [] }}
  end

  def fetch(callback) do
    GenServer.call(__MODULE__, {:fetch, callback})
  end

  def handle_call({:fetch, callback}, from, state) do
    next_state = %{ requests: state.requests ++ [:fetch, callback]}
    {:reply, :ok, next_state}
  end

  def handle_info({:do_next}, state) do
    # Check if anything is running, if not create a new `Task` to run the actual HTTP request
    # and on completion run callback.(response) to reply to the calling GenServer
    Process.send_after(self(), :do_next, 1000)
    {:noreply, state}
  end
end

Method 2: Have GenServers block

In the second method, we’ve tried to make more use of the Task module and have the NetworkResource GenServer block when it’s busy. When we have multiple requests that we want to make all at once, we can also use Task.async_stream to send off a batch and wait for the reply. This gives the advantage of being able to write more linear-looking code, but then the NetworkResource GenServer becomes unresponsive while it’s waiting for network stuff to happen. Depending on how busy the network resource is, we may need a large timeout when making the call.

TheThing GenServer now just manages the local state and database storage and we’ve introduced a TheThingUpdater which manages fetching the resources and providing updates back to multiple instances of TheThing

defmodule Test.TheThing do
  use GenServer
end

defmodule Test.TheThingUpdater do
  use GenServer

  def update(id) do
    GenServer.call(__MODULE__, {:update})
  end

  def handle_call({:update}, from, state) do
    task = Task.async(fn ->
      response = Test.NetworkResource.fetch()

      # Do some stuff, fire off the next request
      # ... response2 = Test.NetworkResource.fetch2()
      # ... response3 = Test.NetworkResource.fetch3()

      {:ok, :ok}
    end)

    next_state = %{ running: state.running ++ [task.ref] }
    {:reply, :ok, next_state}
  end

  def handle_info(msg, state) do
    next_state = case msg do
      {sender, {:ok, value}} ->
        next_running = Enum.filter(state.running, fn task -> task != sender end)
        %{state | running: next_running}
      _ -> state
    end

    {:noreply, next_state}
  end
end

defmodule Test.NetworkResource do
  def fetch(callback) do
    GenServer.call(__MODULE__, {:fetch}, :infinity) # Replace infinity with something more reasonable like an hour
  end

  def handle_call({:fetch, callback}, from, state) do
    # Make the http request using HTTPoison etc and handle 429 errors
    # ...

    {:reply, http_response_body, state}
  end
end

Obviously, our actual use case is more complex than this example, it has multiple requests and a couple of different rate-limited resources. We’re just after some guidance on some of the recommended patterns for doing stuff like this and some pitfalls that we might fall into before ploughing more effort into what we’re doing. Thanks!

Most Liked

dimitarvp

dimitarvp

Have you checked this article?

It’s basically about spawning a background Task to eventually reply to the GenServer’s caller. It uses :noreply as a return value in handle_call callbacks as a mechanism to not block the message queue of a GenServer.

dimitarvp

dimitarvp

No worries, we’re here to help. Modern programming does not have a problem with a lack of tools; it has a problem with the discoverability of the tools.

Word of caution: the article does not go all the way. Meaning that you should not indiscriminately spawn unlimited amount of tasks. You’d need a second layer of controls about how many such tasks can be spawned at the maximum, enforced either by libraries like :jobs or opq, or your own DynamicSupervisor with a helper function e.g. start_child but with extra logic (f.ex. a counter of how many tasks are currently active etc.).


If you figure you’d use opq then the following could be a good implementation (as opposed to the indiscriminate Task.async spawning in the article):

# Somewhere in your app init (or make it part of your supervision tree).
# Give it a generous limit of workers: the BEAM VM can handle
# a lot of processes which gives you a leeway for stuff to catch up.
# Additionally, a conservative timeout is advised.
queue = OPQ.init(name: :our_workers, workers: 1_000, timeout: 30_000)

and:

# In your `GenServer`:

  @impl GenServer
  def handle_call({:do_the_thing, n}, from, state) do
    OPQ.enqueue(:our_workers, fn ->
      # Do long work here...
      nom_nom_nom(long_work)

      # ...and return.
       GenServer.reply(from, return_value_here)
    end)

    {:noreply, state}
  end

This, combined with the architecture from the article, gives you:

  1. A non-blocking GenServer (while still utilizing it as a bottleneck so as only one process manages certain state or fans out the calls to 3rd party APIs)
  2. A generous limit and timeout for each background task spawned by the GenServer
  3. Protection (backpressure) in extreme circumstances e.g. the GenServer will eventually block waiting for OPQ.enqueue if there are currently 1000 busy background tasks.

IMO pretty neat. I’ve done such things multiple times in my Elixir career and they are rock-solid – unless you need each worker to be persistent and never lost in which case I’m afraid you’d be much better off either paying for Oban Pro or rolling your own persistent (as in: backed by a database) workers.

Where Next?

Popular in Questions Top

lastday4you
I wanted to check elixir version in phoenix because i found that my elixir is 1.5 but when i use Enum.chunk_by it said the function is un...
New
Patoshizzle
After calling mix ecto.create I get this error: 17:00:32.162 [error] GenServer #PID<0.412.0> terminating ** (Postgrex.Error) FATAL...
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
Jim
As a follow up to my earlier question: I have the code compiling and running but not getting a successful login from the rest server. ...
New
Kagamiiiii
Student & New to elixir. Nice language. I want to convert a english character, e.g. “a”, which is stored in a variable, to it’s asci...
New
vac
Hi, I'm quite new in Elixir and I'm trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and ...
New
chensan
I have a User schema with a :from_id field set to type :string: defmodule TweetBot.Repo.Migrations.CreateUsers do use Ecto.Migration ...
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New

Other popular topics Top

rms.mrcs
Hi, I need to transform a list of numbers into a map where the keys are the indexes and the values are the original values of the list....
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
AstonJ
You’re a programmer, so you don’t need spoon feeding with the conventional drivel about “this is an integer.” No. You need to know what’s...
New
hariharasudhan94
I would like to know what is the best IDE for elixir development?
New
KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 35421 110
New
chrismccord
As promised, the first release candidate of Phoenix 1.3.0 is out! This release focuses on code generators with improved project structure...
New
mgjohns61585
Could someone help me? I'm making my first elixir program, number guessing game. I can't figure out how to convert the user's guess from ...
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
aesmail
Hello guys, I have finally made it. I created an admin interface for a framework. It’s been on my todo list for years and with the curre...
New

We're in Beta

About us Mission Statement