In the last episode, we added support for oEmbed to our posts. However, if any of the oEmbed requests take too long, a timeout will be triggered which breaks everything. Instead, we'd like to do two things:

  • Add an LRU cache that can store results of oEmbed requests for a given URL.
  • In the event of a timeout, act like the oEmbed had no data rather than crash the linked process.

Let's get started.

Project

We'll start off by looking at how we can h andle the timeouts. If we're running this in the background, I think we can handle more than a 5 second timeout - I've seen Flickr, for instance, take longer than that and time out. We'd love to protect the user from this, but at present it would require sending a Pull Request to modify the way that elixir-oembed uses HTTPoison to include the recv_timeout option. So we'll skip that for now.

Next, let's move on to caching results. To do this, we'll introduce an LRU cache. There's a package that provides an LRU Cache where you can set the number of cache entries, lru_cache.

We'll install it:

vim mix.exs
defmodule FirestormWeb.Mixfile do
  # ...
  defp deps do
    [
      # ...
      {:lru_cache, "0.1.1"},
      # ...
    ]
  end
  # ...
end
mix deps.get

We'll supervise an LRU Cache called :oembed_cache when we start our application:

vim lib/firestorm_web/application.ex
defmodule FirestormWeb.Application do
  # ...
  defp default_children() do
    [
      # ...
      # Add an LRU Cache for storing OEmbed results for a given URL, storing at
      # most 5000 results at a time in the cache.
      worker(LruCache, [:oembed_cache, 5_000])
    ]
  end
  # ...
end

Next, we'll add a module that handles our oEmbed bits, called FirestormWeb.OEmbed. It will have the same for function as the OEmbed package has, but we'll handle checking the cache and putting a result in the cache:

defmodule FirestormWeb.OEmbed do
  @cache_name :oembed_cache

  def for(url) do
    case get(url) do
      nil ->
        case OEmbed.for(url) do
          {:ok, result} ->
            LruCache.put(@cache_name, url, result)
            {:ok, result}
          {:error, reason} ->
            {:error, reason}
        end
      result ->
        {:ok, result}
    end
  end

  def get(url) do
    LruCache.get(@cache_name, url)
  end
end

Now when we ask for something once, it does the work, but the second time it should just fetch it from ets. Let's verify that in the REPL:

# The first fetch takes a while
iex(1)> FirestormWeb.OEmbed.for("https://www.youtube.com/watch?v=H686MDn4Lo8")
{:ok,
 %OEmbed.Video{author_name: "Empire City Elixir Conference",
  author_url: "https://www.youtube.com/channel/UCIYiFWyuEytDzyju6uXW40Q",
  cache_age: nil, height: 270,
  html: "<iframe width=\"480\" height=\"270\" src=\"https://www.youtube.com/embed/H686MDn4Lo8?feature=oembed\" frameborder=\"0\" allowfullscreen></iframe>",
  provider_name: "YouTube", provider_url: "https://www.youtube.com/",
  thumbnail_height: 360,
  thumbnail_url: "https://i.ytimg.com/vi/H686MDn4Lo8/hqdefault.jpg",
  thumbnail_width: 480, title: "Real World Elixir Deployment // Pete Gamache",
  type: "video", version: "1.0", width: 480}}

# The second time is instantaneous
iex(2)> FirestormWeb.OEmbed.for("https://www.youtube.com/watch?v=H686MDn4Lo8")
{:ok,
 %OEmbed.Video{author_name: "Empire City Elixir Conference",
  author_url: "https://www.youtube.com/channel/UCIYiFWyuEytDzyju6uXW40Q",
  cache_age: nil, height: 270,
  html: "<iframe width=\"480\" height=\"270\" src=\"https://www.youtube.com/embed/H686MDn4Lo8?feature=oembed\" frameborder=\"0\" allowfullscreen></iframe>",
  provider_name: "YouTube", provider_url: "https://www.youtube.com/",
  thumbnail_height: 360,
  thumbnail_url: "https://i.ytimg.com/vi/H686MDn4Lo8/hqdefault.jpg",
  thumbnail_width: 480, title: "Real World Elixir Deployment // Pete Gamache",
  type: "video", version: "1.0", width: 480}}
iex(3)>

We should use this in our OembedExtractor:

defmodule FirestormWeb.Forums.OembedExtractor do
  # ...
  def get_embeds(body) do
    # ...
    |> Task.async_stream(fn url -> {url, FirestormWeb.OEmbed.for(url)} end)
    # ...
  end
  # ...
end

Now, let's handle the second problem - if this takes too long, we'll crash. The way I'd like to handle this is to actually fill the cache in a background process that's unlinked. This means that posts should come back immediately, since we simply won't wait on the oEmbed data ever - the cache is filled in the background. We can do this inside of our OEmbed module:

defmodule FirestormWeb.OEmbed do
  @cache_name :oembed_cache

  def for(url) do
    case get(url) do
      nil ->
        Task.start(fn() ->
          case OEmbed.for(url) do
            {:ok, result} ->
              LruCache.put(@cache_name, url, result)
              {:ok, result}
            {:error, reason} ->
              {:error, reason}
          end
        end)
        {:error, "Fetching to populate cache, check again later."}
      result ->
        {:ok, result}
    end
  end

  def get(url) do
    LruCache.get(@cache_name, url)
  end
end

Now, we'll never have the end user suffer from our addition of oEmbed data. This is the sort of thing that makes Elixir amazing - implementing this anywhere else almost certainly would have required introducing a background processor and a separate caching mechanism! One final fix is required - we have to pre-populate the cache in order to keep our test passing that verifies oEmbeds are visible:

vim test/feature/threads_test.exs
defmodule FirestormWeb.Feature.ThreadsTest do
  # ...
  test "thread posts have oembeds rendered", %{session: session} do
    import Page.Thread.Show

    url = "https://www.youtube.com/watch?v=H686MDn4Lo8"
    {:ok, [elixir]} = create_categories(["Elixir"])
    {:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@dailydrip.com", name: "Josh Adams"})
    {:ok, otp_is_cool} = Forums.create_thread(elixir, user, %{title: "Thread with oEmbed in the first post", body: "This is a cool video, check it out: #{url}"})

    # Pre-populate the OEmbed cache to return an oembed for this URL:
    LruCache.put(:oembed_cache, url, %OEmbed.Video{html: "lolno"})

    session
    |> log_in_as(user)
    |> visit(category_thread_path(FirestormWeb.Web.Endpoint, :show, elixir, otp_is_cool))
    |> assert_has(oembed_for("https://www.youtube.com/watch?v=H686MDn4Lo8"))
  end
  # ...
end

Now if we run that test, it passes. It will introduce a race condition in the OembedExtractorTest though so let's do something similar there:

defmodule FirestormWeb.Forums.OembedExtractorTest do
  # ...
  test "returns a list of oembed data for a given block of text, by extracting links" do
    url = "https://www.youtube.com/watch?v=H686MDn4Lo8"
    # ...
    empire_city_elixir_conf_vid =
      %OEmbed.Video{author_name: "Empire City Elixir Conference",
        author_url: "https://www.youtube.com/channel/UCIYiFWyuEytDzyju6uXW40Q",
        cache_age: nil, height: 270,
        html: "<iframe width=\"480\" height=\"270\" src=\"https://www.youtube.com/embed/H686MDn4Lo8?feature=oembed\" frameborder=\"0\" allowfullscreen></iframe>",
        provider_name: "YouTube", provider_url: "https://www.youtube.com/",
        thumbnail_height: 360,
        thumbnail_url: "https://i.ytimg.com/vi/H686MDn4Lo8/hqdefault.jpg",
        thumbnail_width: 480, title: "Real World Elixir Deployment // Pete Gamache",
        type: "video", version: "1.0", width: 480}

    LruCache.put(:oembed_cache, url, empire_city_elixir_conf_vid)

    assert [{url, empire_city_elixir_conf_vid}] == OembedExtractor.get_embeds(example_text)
  end
end

With that, all our tests should pass reliably!

Summary

In today's episode, we made our oEmbed support a lot nicer, by hiding the hard work behind the same interface we were already using and simply switching out the module we're wrapping with our own. I hope you enjoyed it. See you soon!

Resources