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@smoothterminal.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!
Comments for Making oEmbed Not Terrible