Using Built-in Plugins

As we noted at the start, a plugin in Absinthe is any module that implements the Absinthe.Plugin behaviour. It is not uncommon for a plugin module to also implement the Absinthe.Middleware behaviour, because the two behaviours work together. The middleware callbacks handle changes that need to happen to each individual field, and the plugin callbacks operate at the document level.

We’ll start by looking at two simple plugins built into Absinthe itself. These will help us get the hang of how plugins work, and each has use cases where they’re the perfect tool for the job.

Async

A step in the direction of efficient execution would be to run each field concurrently. It doesn’t get rid of the N+1 query, but it does mean that by doing all the N at the same time, we can improve our response time. While obviously not the optimal solution for SQL-based data, async execution is a useful tool when dealing with external APIs. Async is one of the simplest plugins, so let’s give it a look as a way to get our feet wet.

Let’s head back to our category_for_item/3 resolver function and make it async. To do this, we’ll make use of a helper built into Absinthe—async/1—which will import from the Absinthe.Resolution.Helpers module.

 import​ Absinthe.Resolution.Helpers, ​only:​ [​async:​ 1]
 # Rest of file
 def​ category_for_item(menu_item, _, _) ​do
  async(​fn​ ->
  query = Ecto.assoc(menu_item, ​:category​)
  {​:ok​, PlateSlate.Repo.one(query)}
 end​) |> IO.inspect
 end

The change to the resolution function is very small; we’re wrapping the body of the function in an 0-arity anonymous function and then passing that to async/1, much like you would with Task.async. You’ll notice that we’ve added an IO.inspect after our async call, and if we run a query, we’ll see a return value that we’ve never seen before:

 $​ iex -S mix
 iex> query = ​"""
 {menuItems(filter: {name: "Reu"}) { category { name } id } }
 """
 iex> Absinthe.run(query, PlateSlateWeb.Schema)
 
 {​:middleware​, Absinthe.Middleware.Async, {
 #Function<0.33547832/0 in PlateSlateWeb.Resolvers.Menu.category_for_item/3>,
  []
 }}

This is new! Every resolver we’ve written so far has returned either an {:ok, value} or {:error, error} tuple. Here, though, we’re seeing the third and final tuple, which has the form {:middleware, MiddlewareModule, options} and amounts to telling Absinthe, “Hey, hand off the execution of this field to this middleware with these options.” In our specific case then, Absinthe is going to call the Absinthe.Middleware.Async.call function with the field’s resolution struct, and then pass it the options tuple we see at the end there. It houses the function we have in our category_for_item/3 and an empty list of options.

In fact, the entire contents of the async/1 helper is just:

 def​ async(fun, opts \ []) ​do
  {​:middleware​, Middleware.Async, {fun, opts}}
 end

Run a query that gets all the menu items, paying attention to the tracing output this time.

 $ DEBUG=true iex -S mix
 iex> Absinthe.run("{menuItems { category { name } id }}",
  PlateSlateWeb.Schema)

The menuItems field returns the full list of items, and then you can see it starting the category field on the first item. However, instead of completing, the next thing you see is it starting and finishing the id field. Then it starts the category field of the next menu item! As you keep going down, you’ll actually begin to see database output as the function happens in the background while Absinthe is still processing the document.

Only after all the category fields have been started do you see Absinthe completing any of them with values. Nothing in the async helper seems to be doing anything to spawn processes though, so the work has to be done inside the Middleware.Async module. Let’s dive in:

 defmodule​ Absinthe.Middleware.Async ​do
  @behaviour Absinthe.Middleware
  @behaviour Absinthe.Plugin
 
 def​ before_resolution(exec) ​do
  put_in(exec.context[__MODULE__], false)
 end
 
 def​ call(%{​state:​ ​:unresolved​} = res, {fun, opts}) ​do
  task_data = {Task.async(fun), opts}
 
  %{res |
 state:​ ​:suspended​,
 context:​ Map.put(res.context, __MODULE__, true),
 middleware:​ [{__MODULE__, task_data} | res.middleware]
  }
 end
 def​ call(%{​state:​ ​:suspended​} = res, {task, opts}) ​do
  result = Task.await(task, opts[​:timeout​] || 30_000)
  Absinthe.Resolution.put_result(res, result)
 end
 
 def​ after_resolution(exec), ​do​: exec
 
 def​ pipeline(pipeline, exec) ​do
 case​ exec.context ​do
  %{__MODULE__ => true} ->
  [Absinthe.Phase.Document.Execution.Resolution | pipeline]
  _ ->
  pipeline
 end
 end
 end

There’s a lot going on here! Notably, this module is implementing both the Absinthe.Middleware and the Absinthe.Plugin behaviours. The first makes sure we can hook into individual fields when they need to use Dataloader, and the other provides us before and after resolution callbacks. We’re going to walk through this step by step, keeping in mind our GraphQL query:

 {
  menuItems {
  category { name }
  id
  }
 }

The first thing that happens, as the name suggests, is the before_resolution/1 callback. The value passed to this function is an %Absinthe.Blueprint.Execution{} struct, from which every field’s %Absinthe.Resolution{} struct is derived. The before_resolution/1 callback is a good place to set up values we may need later. In this case, we just set a flag to false within our context. This context is the exact same one we’ve been using to store the current user for authentication. The flag will be used later to figure out whether any processes are running in the background or not. Since we just started, none are.

As execution proceeds, Absinthe will hit our :category field, which hands off to this middleware’s call function via the :middleware tuple we saw inside the async/1 function. This is 100% the same def call callback that we looked at when we were doing the error handling or authorization middleware.

Notably, we actually have two clauses here. The first one we’ll hit immediately at the end of our resolver; since no result has been placed on our field, the state is still :unresolved. Here we find where the actual asynchronous action happens! This clause does four things: calls Task.async/1 with our function, suspends resolution, sets our context flag to true, and then updates the field’s middleware to re-run this module when the field is unsuspended. The context part is pretty simple, in that now that there is definitely a process running in the background, we need to set the flag to true so that we know about it later.

The other two changes are a bit less simple. When you suspend the resolution struct, Absinthe stops doing any further processing to that field and moves on to the next sibling field. If you suspend the category field, it stops doing work on that field and moves on to the id field. The name field is unreachable until after category has finally resolved. After doing the id field of the first menu item, it would move to the next menu item to begin its category field (which also pushes a value into the loader) and then suspend.

This makes sense of the tracing output we saw earlier, where :category fields kept being started, but then Absinthe would just move on to the next field. When Absinthe comes back to this field, it needs a way to turn this task back into an actual value that it can continue resolution with, so we use the same trick we learned in the Debug module to re-enqueue our middleware. This time though, instead of adding it at the end, we add the middleware and our task to the beginning so that it will be the very next thing to run. When Absinthe comes back to the field, it’ll run this module again, and we’ll have the opportunity to Task.await/1 and get a value.

After Absinthe has completed this particular walk through the document, it runs the after_resolution callback. This is an opportunity to do any extra transformations or loading, but for our purposes, we don’t need to do anything.

The Absinthe.Phase.Document.Execution.Resolution phase we’ve been inside this whole time only does a single walk through the document. Now that we’re executing certain fields asynchronously, however, this is a problem, because we need to go back and get values out of each task. This brings us to the last and most interesting callback: pipeline. Based on the execution struct we returned from after_resolution, our plugin has the option to tell Absinthe to run additional phases on the document. We’re putting that flag in our context we’ve been tracking to good use; if it’s been set to true, then we know there are async fields happening, and we need to go back to await them. If the flag is false, then as far as this plugin is concerned, there’s nothing more to be done, so we leave the pipeline alone.

Graphically, this execution flow looks like this:

images/chp.performance/pipeline-resolution.png

With this additional phase specified, Absinthe knows it has more work to do, effectively starting the whole process over again. As Absinthe walks through the document, it will come across the first suspended field, calling whatever remaining middleware exists on that field. Of course, that’s just Middleware.Async clause number two because that’s what we set up the first time around. All we need to do is just Task.await on our process and put the result on the field.

Batch

The problem with async, of course, is that while it’s faster than serial database queries, we’re still doing N of them to get N categories when we could really be doing just one database query to get all the categories. We need a way to aggregate values, a function that can use those aggregated values to run an SQL query, and then the ability to get those values back into individual fields.

Fortunately, the Absinthe.Middleware.Batch plugin has our backs. Let’s see how it looks in our resolver:

 import​ Absinthe.Resolution.Helpers, ​only:​ [​batch:​ 3]
 # Rest of file
 def​ category_for_item(menu_item, _, _) ​do
  batch({PlateSlate.Menu, ​:categories_by_id​}, menu_item.category_id, ​fn
  categories ->
  {​:ok​, Map.get(categories, menu_item.category_id)}
 end​) |> IO.inspect
 end

As before, we’ve got an Absinthe helper function we’re importing to provide a nice API, as well as an |> IO.inspect at the end, so we’ll be able to see in a second what this function returns. The function takes three arguments: a module and function tuple indicating what function will actually run the batch, a value to be aggregated, and then a function for retrieving the results specific to this field.

The function specified, PlateSlate.Menu.categories_by_id/2, looks like this:

 def​ categories_by_id(_, ids) ​do
  Category
  |> where([c], c.id ​in​ ^Enum.uniq(ids))
  |> Repo.all
  |> Map.new(​fn​ category ->
  {category.id, category}
 end​)
 end

The body of this function gives us a pretty good idea of what’s going to happen. The resolver function is aggregating menu_item.category_ids, and those will get passed in as the second arg of the categories_by_id function. Within that function, we have a simple Ecto query that grabs all the categories with the ids we want, and then we make a map of category IDs to the associated category for easy lookup later. Let’s give this a whirl:

 $​ DEBUG=true iex -S mix
 iex> Absinthe.run(​"""
 ...> {menuItems(filter: {name: "on"}) { category {name} } }
 ...> """​, PlateSlateWeb.Schema)
 ======================
 starting:​ menuItems
 with​ ​source:​ %{}
 [debug] QUERY OK source=​"​​items"​ db=7.​7​ms decode=1.​5​ms
 SELECT i0.​"​​id"​, i0.​"​​added_on"​, ...
 FROM ​"​​items"​ AS i0 WHERE (i0.​"​​name"​ ILIKE ​$​1) ORDER BY i0.​"​​name"​ [​"​​%on%"​]
 completed:​ menuItems
 value:​ [%PlateSlate.Menu.Item{...}, %PlateSlate.Menu.Item{...}]
 ======================
 ======================
 starting:​ menuItems.0.category
 with​ ​source:​ %PlateSlate.Menu.Item{...}
 {​:middleware​, Absinthe.Middleware.Batch,
  {{PlateSlate.Menu, ​:categories_by_id​}, 1,
 #Function<0.54233969/1 in PlateSlateWeb.Resolvers.Menu.category_for_item/3>,
  []}}
 ======================
 starting:​ menuItems.1.category
 with​ ​source:​ %PlateSlate.Menu.Item{...}
 {​:middleware​, Absinthe.Middleware.Batch,
  {{PlateSlate.Menu, ​:categories_by_id​}, 3,
 #Function<0.54233969/1 in PlateSlateWeb.Resolvers.Menu.category_for_item/3>,
  []}}
 [debug] QUERY OK source=​"​​categories"​ db=1.​9​ms
 SELECT c0.​"​​id"​, c0.​"​​description"​, c0.​"​​name"​, c0.​"​​inserted_at"​, c0.​"​​updated_at"
 FROM ​"​​categories"​ AS c0 WHERE (c0.​"​​id"​ = ANY(​$​1)) [[3, 1]]
 completed:​ menuItems.0.category
 value:​ %PlateSlate.Menu.Category{​name:​ ​"​​Sandwiches"​, ...}
 ======================
 ======================
 starting:​ menuItems.0.category.name
 with​ ​source:​ %PlateSlate.Menu.Category{​name:​ ​"​​Sandwiches"​, ...}
 completed:​ menuItems.0.category.name
 value:​ ​"​​Sandwiches"
 ======================
 completed:​ menuItems.1.category
 value:​ %PlateSlate.Menu.Category{​name:​ ​"​​Beverages"​, ...}
 ======================
 ======================
 starting:​ menuItems.1.category.name
 with​ ​source:​ %PlateSlate.Menu.Category{​name:​ ​"​​Beverages"​, ...}
 completed:​ menuItems.1.category.name
 value:​ ​"​​Beverages"
 ======================
 {​:ok​,
  %{​data:​ %{​"​​menuItems"​ => [%{​"​​category"​ => %{​"​​name"​ => ​"​​Sandwiches"​}},
  %{​"​​category"​ => %{​"​​name"​ => ​"​​Beverages"​}}]}}}

This is looking good! As with async, we see that when we get to the first menuItems.0.category field, we start it but then immediately move on to the menuItems.1.category field. Our debug Ecto output shows that we do a single SQL query for two categories, IDs 3 and 1, and then each category field completes without any further database querying.

Logger Prints Asynchronously

images/aside-icons/info.png

Depending on your computer, you may see the SQL debug output show up at different points amid the output from your Debug middleware. This is because Elixir’s Logger maintains a small buffer for performance reasons, so output may be delayed. We’re using IO.puts in our debugger, which outputs immediately. If you see the SQL query show up after one of the category fields shows as complete, that’s the reason why!

We aren’t going to dive into the actual code of the Absinthe.Middleware.Batch because the code for managing the batches is a little too much to fit into a book. From what we saw within the Async plugin, though, we can see that a similar process is at work. The fact that we started each category field before we completed any of them tells us that the plugin is suspending each field as it internally builds up a batch under each function, and the fact that each field ultimately completes tells us it’s doing the trick of modifying the middleware to come back. Then, each batch is run during the after_resolution callback. If there were any batches to run, the resolution phase happens once more, and results can be filtered into the proper fields.

The Absinthe.Middleware.Batch achieves a lot and, with some helpers, was the standard way to solve this problem for a long time. While batch still has a place, it has a few limitations that have driven the development of the final approach we’ll look at in this chapter. There are small-scale annoyances like the limitation of only being able to batch one thing at a time in a field, or the fact that the API can get very verbose.

There are also some larger-scale issues. Ecto has a fair number of quirks that make it a difficult library to abstract access to. If you want the concurrent testing feature to work, you need to add self() to all the batch keys and do Repo.all(caller: pid) in every batch function so that it knows which sandbox to use. It gets easy for your GraphQL functions to become full of direct database access, inevitably going around important data access rules you may want to enforce in your Phoenix contexts. Alternatively, your context functions can end up with dozens of little functions that only exist to support batching items by ID.

In time, people involved in larger projects were able to build some abstractions, helpers, and conventions around the Absinthe.Middleware.Batch plugin that did a good job of addressing these issues. That effort has been extracted into the project Dataloader, which, while a generic tool not tied to GraphQL, is the perfect fit for what we’re trying to accomplish here.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.117.7.131