quda

quda

Phoenix json API extremely slow when serving CSV files

I am building a json REST API with Phoenix that (among other services) has to convert static CSV files from a repository and “serve” them as response in json format; that will be used downstream by other services in our project. Some of these csv files are quite big, ranging for a several hundred kb to tens of MB.
So far so good, I made it work with several lines of code.
So, in my backend side:

 defp csv(file) do
   file
    |> Path.expand(__DIR__)
    |> File.stream!()
    |> CSV.decode(headers: true)
    |> Enum.map(fn {:ok, val} -> val end)
  end

in the API controller:

  def show(conn, bhandler) do
    conn
    |> Plug.Conn.put_resp_header("content-type", "application/json; charset=utf-8")
    |> Plug.Conn.send_resp(200, Jason.encode!(bhandler, pretty: true))
  end
end

The program is doing well what is supposed to do but on small files.
When the file gets bigger and response time gets exponentially bigger:

  • for 69kb (csv file) → 124ms :grinning:
  • 234kb → 2.8s :slightly_smiling_face:
  • 1.1MB → 24s :neutral_face:
  • 22.4MB → 835s :flushed:

Obviously something is very wrong with these implementation, or with its libraries (CSV, Jason). A similar (simple) API we have, in php, took a few of seconds to read/convert/send/receive 40MB csv file.

Do you have any suggestion ?

Marked As Solved

lud

lud

I used the 10000_sales.csv file from NimbleCsv, I duplicated the content until the total size was 20M (19.2 Mio). It has 15 columns.

Now with this code using chunks it takes 2.8 seconds with wget to download the JSON data (size is 50M), although in the browser it loads forever.

defmodule XsvWeb.PageController do
  use XsvWeb, :controller

  alias NimbleCSV.RFC4180, as: CSV

  def index(conn, _params) do
    conn = send_chunked(conn, 200)
    send_csv(conn, "big_sales.csv")
  end

  defp send_csv(conn, file) do
    csv_stream = csv_stream(file)
    {:ok, conn} = chunk(conn, "[")

    conn =
      Enum.reduce_while(csv_stream, conn, fn chunk, conn ->
        case Plug.Conn.chunk(conn, chunk) do
          {:ok, conn} ->
            {:cont, conn}

          {:error, :closed} ->
            {:halt, conn}
        end
      end)

    {:ok, conn} = chunk(conn, "]")
    conn
  end

  defp csv_stream(file) do
    headers = headers(file)
    headers |> IO.inspect(label: ~S[headers])

    stream =
      file
      |> File.stream!()
      |> CSV.parse_stream(skip_headers: true)

    stream
    |> Stream.take(1)
    |> Stream.map(fn x -> Enum.zip(headers, x) |> Map.new() |> Jason.encode_to_iodata!() end)
    |> Stream.concat(
      stream
      |> Stream.drop(1)
      |> Stream.map(fn x ->
        [?,, Enum.zip(headers, x) |> Map.new() |> Jason.encode_to_iodata!()]
      end)
    )
  end

  defp headers(file) do
    file
    |> File.stream!()
    |> Stream.take(1)
    |> CSV.parse_stream(skip_headers: false)
    |> Enum.at(0)
  end
end

Note that the browser makes it slow to display but with fetch("http://localhost:4000/").then(x => x.json()).then(x => console.log('x', x)) it is still less than 4 seconds for 54.8 MB of JSON.

Now @quda I know it is not the original problem but converting to JSON makes the final file size more than twice as big. Is it possible to just send the CSV file? or JSON arrays instead of objects?

That being said I do not think it is possible to beat something as simple as basic PHP like you said:

<?php
$csvFile = file('big_sales.csv');
$data = [];
foreach ($csvFile as $i => $line) {

   if ($i == 0) {
      $headers = str_getcsv($line);
   } else {
      $row = str_getcsv($line);
      $data[] = array_combine($headers, $row);
   }

}

echo json_encode($data, JSON_PRETTY_PRINT);

Also Liked

mmmrrr

mmmrrr

?, means: "give me the codepoint of the comma (in this case it returns 44, which you can try on iex: iex> ?,). You can read about the usages of IO data here: IO — Elixir v1.12.3

So this function does (as I understand it):

    stream
    # Take the first line from the CSV (since we used "skip_headers" this is the first line of data)
    |> Stream.take(1)
    # 1. Merge the headers of the csv with the data we just read into tuples of {HEADER, DATA}
    # 2. Make a new map from that list of tuples
    # 3. Encode the map as json and output it as io_data instead of as string
    |> Stream.map(fn x -> Enum.zip(headers, x) |> Map.new() |> Jason.encode_to_iodata!() end)
    # We now have a list of io_data lists, so something like: `[ [12, 31, 55], [13, 14, 99], ... ]`
    # which goes as the first argument into Stream.concat/2
    |> Stream.concat(
      # The second argument to the concat function
      stream
      # But this time we remove the first line...
      |> Stream.drop(1)
      # ... map over _each other line_ in the csv...
      |> Stream.map(fn x ->
        # ... and then prepend it with a comma in codepoint format
        [?,, Enum.zip(headers, x) |> Map.new() |> Jason.encode_to_iodata!()]
      end)
    )

All this will result in something like this {"hello": "world"}, {"hello": "foo"}, {"hello": "bar"} (notice that we don’t have a comma at the first position?). The send_csv function wraps that into [ and ] which makes it a valid json array. This whole thing with the code points is probably done for performance reasons in this case and I personally have not had the need to use this form. This is relatively advanced stuff for a relatively advanced problem.

So please don’t beat yourself up about this. We’re all here to learn and to give back where we can! This forum, or rather it’s management and the people in it, might just be the best feature of Elixir in general - and that says a lot. :slight_smile:

quda

quda

My petty contribution to the solution of @lud with Stream.intersperse/2:
file

...
|> Stream.intersperse(",")
|> Stream.chunk_every(2)

By applying Stream.every/2 with 2 as argument new chunks are created into the stream by combining the previous records with the comma from intersperse.
Proud of my finding! :relaxed:

dimitarvp

dimitarvp

I didn’t mean to be rude and sorry if it read that way – my point is that without some level of understanding we cannot help you because you might not understand and not be able to apply the suggestions given to you. So then how can we help you?

What I suggested to you is: integrate all processing of the CSV file together in one place so you can reap the full benefits of streaming and save CPU time and memory. If you need an even more detailed example, I’ll be happy to give it to you – can even make a GitHub repo for you if you provide some sample CSV data we can work with. (E.g. how much columns does your CSV file have?)

One of my first tasks with Elixir – 5.5 years ago now – was exactly to process hundreds of megabytes of CSV and XML files per hour. On a virtual hosting with a 4-core vCPU and using full parallelism (using Task.async_stream or Flow) I wasn’t able to load the CPU to more than 60% while still ingesting several CSV / XML files with sizes 500MB+, per hour. There are ways and we here are trying to show you those ways.

I don’t have a horse in this race – just don’t be too quick to discount Elixir as unsuitable. There’s a lot that can be done. And you won’t regret sticking with Elixir. It saves you from a ton of problems that no other language has solved yet (like transparent parallelism and concurrency).

Where Next?

Popular in Questions Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
nobody
How to bind a phoenix app to a specific ip address? could not find anything about that, nowhere, unfortunately, but for me this is quite...
New
earth10
Hi, I’m just starting to build a side-project with Elixir and Phoenix and doing some basic test with Elixir alone. What strikes me is th...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
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
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
komlanvi
Hi everyone, I was playing with phoenix liveView but I run into an issue. I have a form and want to validate each input text when the te...
New

Other popular topics Top

danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 29377 241
New
aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? Ecto.Repo — Ecto v3.14.0 has exampl...
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
belgoros
I’m not a pro in using Regex and can’t figure out why the following behaviour happens, especially if we take into account the difference ...
New
KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 36128 110
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New

We're in Beta

About us Mission Statement