Can Phoenix controllers easily be blocked?

I am learning Elixir and Phoenix, and am a bit confused. Let’s see if I can put down a concise question. Look at this code:

defmodule MyController do
  use MyApp, :controller

  def acme_order_status(conn, params) do
    # This is a contrived example, but you get the idea
    # Acme.order_status/0 is a really slow function, minutes really
    case Acme.order_status() do
      {:ok, status} ->
        conn
        |> put_status(:ok)
        |> json(%{status: status})
      {:error, reason} ->
        conn
        |> put_status(:internal_server_error)
        |> json(%{error: reason})
    end
  end
end

The thing I am confused about is how many processes Phoenix creates. If I have an expensive blocking call in a controller function, will this block the controller for other requests?

This question is just a gateway into a whole world of confusion in my head. I’ve read some documentation and tried out some stuff. Generally it’s been a pleasant ride, but still I get uncertain as soon as I do anything out of the ordinary.

It might be that I miss the wood because of all the trees, but where can I read about how processes are handled in Phoenix? Especially from the perspective of a web app developer, so that I will feel confident I don’t block things.

I found this: How does Phoenix handle incoming HTTP requests? - #2 by benwilson512.

So each request is a process. Then I don’t have to be afraid to block other request. Right?

You generally can block more in Phoenix than in say, Rails. You can have hundreds if not thousands concurrent connections and Phoenix will just wait very efficiently on I/O.

It gets slightly more hairy if the I/O you are waiting for is a database call. Then, you will reach the number of connections in the pool and the other requests will block.

The same can happen if you are using a HTTP client to do that order_status call that has a fixed upper limit on number of workers.

But in principle it’s more fine than in many other environments.

2 Likes

I would say for pure code (that doesn’t deal with other processes or third-party stuff), you have full isolation, so no blocking involved whatsoever*.

In the real world this rarely happens, so it highly depends on what third-party dependencies you are using. As @hubertlepicki mentioned, you can have over-saturation of your database pool, however this problem affects every ecosystem, not just this one.

The way this works is that libraries that deal with databases (for example ecto) spawns a pool of 10 active connections to the database (think of it as 10 processes), those 10 connections are used by all your clients, so if you have 10k clients, you might run into situations where the database or pool might not be responding fast enough to the clients.

* - there is also performance degradation depending on hardware and how many active processes you have, so that might be a factor too, even though on modern hardware, you really have to crank that up to be noticeable.

1 Like

Others already answered well on the DB part – the DB is used via a pool. You can have thousands of requests waiting for stuff no problem but if they also have to use the DB then you’ll run into timeouts. You can extend the timeout values as well but then you have to ask yourself do you really want your users to wait as long?

In terms of the runtime VM you’ll have no trouble. In the case you are outlining you are much more likely to stumble upon HTTP timeouts from up-/down-stream proxies and load balancers than to have the BEAM VM be problematic.

Unsolicited advice: just change this endpoint to poll for the status the users are checking for and return immediately. Maybe you can instruct them to check periodically i.e. every 10 seconds or so.

Thanks for all the answers!

I don’t know why I created such a bad example not close to the actual problem I am facing.

This is closer to the real problem:

defmodule AuthController do
  use MyAppWeb, :controller

  def login(
    _conn,
    %{"order_ref" => order_ref}
  ) do
    case MyAppWeb.BankID.authenticate(order_ref) do
      {:success, identity_id} ->
        # login logic
      {:failed} ->
        # failed login logic
    end
  end
end

I am implementing authentication using Swedish BankID. Some stuff happens in the client, and the app calls the controller function to start polling for a login status. The user is then using another app to authenticate via which I don’t have any control over.

MyAppWeb.BankID is a genserver that do the polling and returns a result. It calls a server every second or so.

Does this sound like a good approach? Or maybe I’m just adding unnecessary complexity by putting the bankid stuff in a genserver?

Is it a genserver per http request waiting or one genserver per node? The latter can become a problem like described above for the db pool. The former can be ok for e.g. failure isolation – genserver crashes, but you still send a proper error to the http request. But you’d want to figure out why you want the extra process there, because you already have an individual process handling the request as well.

1 Like

I’m a noob, so have patience with me :slight_smile:

I did not decide whether it should be one genserver per request, or one that all requests call. Right now it’s only one, but it feels a bit dangerous, because I guess it could potentially block all requests, right?

Now, with my knew knowledge knowing I have access to a process per request I guess I could just ditch the genserver. Then I just have to figure out how to create the polling loop.

No worries. This was mostly a hint that there’s a decision to be made.

Tbh that might already be enough to spawn a separate process to do the waiting – though I’d suggest either a pool of them or simply one per request doing the waiting, not one per node. Depends on your constraints around the external system you’re awaiting on.

That raises some red flags right there if you’re going to do it wrong you’re going to suffer :smiley:

Thanks for your input. You are asking very good questions.

I don’t expect a massive load and am not that afraid of spawning processes. At this stage I want the simplest possible code, which I guess is one per request.

I’ll wait for a couple of minutes or so before I’ll abort.

… not one per node

You mean one genserver for all requests is a bad approach?

What flags do you see? Am I doing some classic beginner mistake?

As always - depends. A single process can do a lot of work, but it’s also obviously something, which can become a bottle neck given enough load.

I guess the general feeling is that banks usually ban first then ask questions later, so you need to get the request topology in order and have a proper rate-limiter.

I’m targetting a test server now, but yes, I need to know what I’m doing before targetting production :sweat_smile:

Thanks for all your input!

I think I have a solution now, and looking at it, it’s surprisingly clear and simple now that I removed all needless stuff.

1 Like