Running Task.async_stream/2 to render large list in templates

Hi all,

I’ve got a big table that is taking up to 0.5 seconds to build.

There are some steps that I need to make in order to reduce that, which include:

  • pagination of some kind (reduce the size of the table)
  • run more of my logic higher up, before it get’s to the templates

But I want a quick fix as this is in production, and we are not currently being given time. I was wondering if I could generate the rows async which would certainly speed up the process. But wasn’t sure how I’d go about writing that?

An example of what I’d like to do:

      <%= Task.async_stream(users, fn user -> %>
        <%= render "components/user_row.html", user: user %>
      <% end) %>

The first thing I’d say is: Are you absolutely sure that the 500ms is spent doing render "components/user_row.html", user: user and not something else? If so, what method did you use to determine this? My intuition would be that the 500ms is more likely spent loading the data.

Hey @benwilson512 , thanks for the reply.

That’s interesting.

The method used was using a little helper module to check time elapsed since start_timer() was called:

  def start_timer() do
    System.monotonic_time()
  end

  def check_time_since(return_val, compare_timepoint, log_reference) do
    timepoint = System.monotonic_time()

    diff =
      (System.convert_time_unit(timepoint - compare_timepoint, :native, :millisecond) /
         1_000)
      |> Float.round(6)

    Logger.info("#{log_reference}: #{diff}s")

    return_val
  end

Which I used like this:

<% timepoint = start_timer() %>
  <%= for user <- users do %> 
    <%= render "components/user_row.html", user: user %> 
  <% end %>
<% check_time_since(timepoint, "Table written" %>

It repeatedly came out as between 0.3 and 0.5 seconds when testing locally.

My timing method is not perfect but good for quickly working out some bottle-necks. If there’s a better method you’d recommend that would be awesome too!

1 Like

can you quantify? 1_000? 10_000? etc?

Well 500 rows, but each row represents a week of data, so 7 overarching columns. Each Day column is rendered collapsed with some basic info showing, then can be expanded showing more data.

So a fully expanded table would stretch well beyond the users screen in both directions. It is pretty huge. I think the Content Download was around 2MB.

Strangely on my local machine that (Content Download) is taking around 6 seconds to complete, whereas on Heroku it takes around a second.

It would be great to hear any opinions on the above, but I want to stress that I know building the rows async would only address a part of the symptom. From my limited understanding paginating is the only way around it.

What do your row and table templates look like?

Hi @axelson , thanks for the input.

The offending table:

    <div id="tc-appr-data" class="apprv-margin-top apprv-margin-left apprv-data-table-dimensions overflow-scroll absolute">
      <table align="left" class="collapse">
        <tbody>
          <%= for {_department, engagements} <- grouped_engagements do %>
            <%= for engagement <- engagements do %>
              <%
                timecards = Map.get(tcs_by_oid, engagement.offer_id, [])
              %>
              <tr class="apprv-row-height f6 ba k-b--lighter-grey">
                <%=
                  render "approver_components/crew_member_row.html",
                  timecards: timecards,
                  conn: @conn,
                  project: @project,
                  approvers: @approvers,
                  current_user: @current_user,
                  week_date_range: week_date_range,
                  col_width: col_width
                %>
              </tr>
            <% end %>
          <% end %>
        </tbody>
      </table>
    </div>

And the component approver_components/crew_member_row.html.
(a bit big)

<%
  tcs_by_date = Enum.group_by(@timecards, & &1.date)
%>
<%= for date <- @week_date_range do %>
  <%
    tc = if tcs_by_date[date], do: tcs_by_date[date] |> hd(), else: nil
    date_string = Date.to_string(date)
    collapsible_column_id = "collapsible-column-#{date_string}"
    border = "ba k-b--lighter-grey "
  %>
  <%= if !tc do %>
    <%=
      render "approver_components/no_tc_column.html",
      col_width: @col_width,
      collapsible_column_id: collapsible_column_id,
      border: border
    %>
  <% else %>
    <%
      can_approve? = check_approvable?(@conn, @project, @approvers, @current_user, tc)
      # check this user can approve this tc to determine which data to show
      tc_data = if can_approve?, do: tc.latest_data_for_approver, else: tc.latest_approved_data
      shoot_day? = Ev2.Timecards.shoot_day?(tc_data)
      original = get_crew_data(tc)
    %>


    <%
      # if tc was auto submitted, use grey font
      base_colour = get_tc_data_base_font_colour(tc, tc_data)
    %>

    <!-- Time columns -->
    <%= if tc_data.straight_day? || !shoot_day? do %>
      <%
        abbreviated_day_type = get_abbreviated_day_type(tc_data)
        original_day_type = get_abbreviated_day_type(original)

        colour =
          case original_day_type == abbreviated_day_type do
            true -> base_colour
            false -> " red"
          end
      %>

      <!-- show day type if either straight day or non shoot day -->
      <td colspan="2" class="tc truncate bl k-b--light-grey <%= "#{@col_width."6"} #{colour}" %>">
        <%= abbreviated_day_type %>
      </td>
    <% else %>
      <%
        column_data = [
          {format_time(original.start_datetime), format_time(tc_data.start_datetime), "bl k-b--light-grey"},
          {format_time(original.finish_datetime), format_time(tc_data.finish_datetime), "ba k-b--lighter-grey"}
        ]
      %>
      <%= for {original, latest, class} <- column_data do %>
        <%
          colour =
            case original == latest do
              true -> base_colour
              false -> " red"
            end
        %>
        <td class="tc <%= "#{@col_width."3"} #{class}" %> <%= colour %>">
          <%= if shoot_day?, do: latest %>
        </td>
      <% end %>
    <% end %>

    <!-- non-time columns -->
    <%
      # TODO: # TODO: # TODO: Use formatted tc data
      column_data = [
        {format_nil(original.camera_ot), format_nil(tc_data.camera_ot), @col_width."3", false, :with_border, :camera_ot},
        {format_nil(original.other_ot), format_nil(tc_data.other_ot), @col_width."3", false, false, :other_ot},
        {format_nil(original.ot.additional_day_penalty), format_nil(tc_data.ot.additional_day_penalty), @col_width."3", :collapsible, :with_border, :unit},
        {get_unit_name(original.unit), get_unit_name(tc_data.unit), @col_width."6", :collapsible, :with_border, :unit},
        {format_locations(original.locations), format_locations(tc_data.locations), @col_width."6", :collapsible, :with_border, :locations},
        {original.upgrade, tc_data.upgrade, @col_width."6", :collapsible, :with_border, :upgrade},
        {format_allowances(original), format_allowances(tc_data), @col_width."4", :collapsible, :with_border, :allowances}
      ]
    %>
    <%= for {original, latest, width, collapsible?, with_border?, atom} <- column_data do %>
      <%
        colour =
          case original == latest do
            true -> base_colour
            false -> "red"
          end
        id = if collapsible?, do: collapsible_column_id
        display = if collapsible?, do: "dn"
        border = if with_border?, do: "ba k-b--lighter-grey"
        latest = atom == :locations && latest != nil && String.contains?(latest, ", ") && "Multi" || latest
      %>
      <td id="<%= id %>" class="tc truncate <%= "#{border} #{width} #{colour} #{display}" %>">
        <%= latest %>
      </td>
    <% end %>

    <!-- approval columns -->
    <td id="<%= collapsible_column_id %>" class="<%= @col_width."2" %> <%= border %> tc dn">
      <%= if !approver_exists?(tc, :dept) do %>
        <!-- no dept approver -->
        n/a
      <% else %>
        <%= if tc_approved_by?(tc, :dept) && tc.approvals_by_type.dept.auto_generated? do %>
          <!-- was auto approved -->
          <span class="k-grey">Auto</span>
          <% else %>
            <%= if tc_approved_by?(tc, :dept) do %>
              <!-- was manually approved -->
              <img class="w1" src="/images/tick-light.svg" />
            <% end %>
          <% end %>
        <% end %>
    </td>
    <td id="<%= collapsible_column_id %>" class="<%= @col_width."2" %> <%= border %> tc dn">
      <%= if tc_approved_by?(tc, :prod) do %>
        <img class="w1" src="/images/tick-light.svg" />
      <% end %>
    </td>
    <td id="<%= collapsible_column_id %>" class="<%= @col_width."2" %> tc dn">
      <%= if tc_approved_by?(tc, :accs) do %>
        <img class="w1" src="/images/tick-light.svg" />
      <% end %>
    </td>
  <% end %>
<% end %>

Sorry that is a lot to pick through. Too much logic in that last component.

Thanks for including the templates! Is there any code in that last template that hits the database? Some of these seem like they might:

      can_approve? = check_approvable?(@conn, @project, @approvers, @current_user, tc)
      # check this user can approve this tc to determine which data to show
      tc_data = if can_approve?, do: tc.latest_data_for_approver, else: tc.latest_approved_data
      shoot_day? = Ev2.Timecards.shoot_day?(tc_data)
      original = get_crew_data(tc)

If so then you should try to make sure that at least none of those are an N+1 query. Here’s a little info about N+1 query if you (or anyone reading this) is not familiar with the term (couldn’t find something more generic so this is from the Absinthe docs): https://hexdocs.pm/absinthe/ecto.html

Also generally it is best to avoid any DB access in your templates, partially to avoid problems like this.

If you don’t have any db queries in that template then I’m not too sure what can be done to improve the rendering performance but you may need to benchmark the different sections of logic (using a tool like https://github.com/bencheeorg/benchee).

Thanks a lot @axelson

No templates don’t hit the DB for sure.

I have benchmarked using my semi-useful tool above. And ye the logic in the offending function can be moved up into controller or something before we get to here.

So, no way to async build the rows then? Can I ask why that is?

Big disclaimer: I don’t really recommend doing this. But you asked if it was possible and I see no reason why not.

I haven’t done it and I don’t have any ready solutions for you, but there’s nothing to stop you from rendering the template manually. It’s gonna be a lot less pretty, but since you’ve exhausted all other options.

You need to build the HTML yourself from partials, replacing the two for in your template with code and creating a template for the row. So you would be able to write something like

rows = Enum.map(grouped_engagements, fn {_departement, engagements} ->
  Enum.map(engagements, fn engagement -> Templates.render("row.eex", engagement) end) end)

table = Templates.render("table.eex", rows: rows)

render(conn, "index.html", table: table)

but figure out all the stuff about where and how. I’ll leave that part to you, just saying, you can of course render templates yourself.

If you’ve got that working, you can parallelize the Enum.map code.

I’m not sure it would run faster at all, it’s probably much harder for the templating engine to optimize the string building.

1 Like