Automatically avoiding GraphQL N+1s
Some people, when faced with an API problem, think “I’ll use GraphQL!” And now they have N+1 problems.
N+1 problems occur when you want to find a deep tree of records and end up performing a SQL statement or API request for every record, instead of retrieving all records — or all records of a given type — at the same time.
In Rails, N+1 problems are simple to solve using includes
:
features_with_comments = release.features.includes(comments: :created_by)
But you still have to remember to do it. At Aha!, we build complex reports and roadmaps. Even though we’re careful to avoid N+1 problems, accidental ones are still a major concern of ours.
Aha! Develop extensions
While creating Aha! Develop, our mission was to offer radical customization for developers so they can do their work in the way that’s best for them. This meant providing an extremely flexible API, making Aha! Develop extensions not only powerful, but easy to write. GraphQL is a perfect fit for this kind of client-driven functionality.
But this API flexibility comes with a cost: It’s hard to optimize queries when you don’t know in advance what the query will be. In order to avoid an explosion of queries when an extension fetches nested data (which will happen all the time!), it seemed like we would need to analyze the query and create a plan for executing it efficiently. That’s a lot of work, complex, and prone to mistakes.
This was a big problem. If we couldn’t provide a flexible API that performed well, we couldn’t provide it at all.
Looking into solutions
Despite the jokes, this is a common enough problem that there had to be some existing solutions. To provide our GraphQL API on the server side, we use graphql-ruby. This is an amazing library that feels like the best of Ruby — it has great defaults while being easy to extend and change. There are a few N+1-avoiding libraries that work well with it:
After some exploration, batch-loader seemed like the perfect solution. It was a little harder to get started with than something GraphQL-specific like graphql-batch. But it was small, flexible, and useful even outside of GraphQL. We’ve even considered using it to batch load requests to external APIs where that is supported.
Here’s what it looks like, from batch-loader’s README:
def load_posts(ids)
Post.where(id: ids)
end
def load_user(post)
BatchLoader.for(post.user_id).batch do |user_ids, loader|
User.where(id: user_ids).each { |user| loader.call(user.id, user) }
end
end
posts = load_posts([1, 2, 3]) # Posts SELECT * FROM posts WHERE id IN (1, 2, 3)
# _ ↓ _
# ↙ ↓ ↘
users = posts.map do |post| # BL ↓ ↓
load_user(post) # ↓ BL ↓
end # ↓ ↓ BL
# ↘ ↓ ↙
# ¯ ↓ ¯
puts users # Users SELECT * FROM users WHERE id IN (1, 2, 3)
It is a really small amount of code and does exactly what we want. But how could it be easily integrated with the GraphQL code?
Making it trivial
Knowing that multiple people would have to maintain the API over time, we wanted to make the right way to avoid N+1s obvious and take as little effort as possible. We also wanted to avoid the cost of preloading a field if we didn’t request it.
When developing new features, one thing we do at Aha! is define our ideal interface first and do what’s necessary behind that to provide the ideal. For this case, this is what we wanted to write in our GraphQL types:
module Types
class FeatureType < Types::BaseObject
field :requirements, [RequirementType], null: false, preload: :requirements
end
end
All that’s necessary to preload the requirements when they’re fetched off of a feature is to add that preload:
argument, which uses the same pattern as Rails' includes
does.
Sometimes, when you dream up a clean interface, the implementation has to become more complex to support it. Thanks to graphql-ruby
and batch-loader
, that wasn’t the case here. This is all you need:
class Types::PreloadableField < Types::BaseField
def initialize(*args, preload: nil, **kwargs, &block)
@preloads = preload
super(*args, **kwargs, &block)
end
def resolve(type, args, ctx)
return super unless @preloads
BatchLoader::GraphQL.for(type).batch(key: self) do |records, loader|
ActiveRecord::Associations::Preloader.new.preload(records.map(&:object), @preloads)
records.each { |r| loader.call(r, super(r, args, ctx)) }
end
end
end
Look how tiny it is! So what is this doing?
How it works
In graphql-ruby
, a Field instance is created when you use the field
method to define a field, like this from the example above:
field :requirements, [RequirementType], null: false, preload: :requirements
If you look at the PreloadableField implementation at the end of the last section, it uses that preload
argument to add a little bit of behavior in the resolve
method.
resolve
is called on a field when GraphQL Ruby needs to get the data from that field. For example, it might be called on the requirements
field when feature.requirements
is called.
The type
argument is the GraphQL type object that contains that field. For example, if a GraphQL FeatureType is preloading RequirementTypes, type
will be an instance of FeatureType and type.object
will be the Feature that FeatureType instance is wrapping.
This is where things get fun.
BatchLoader::GraphQL.for(type)...
You can think of BatchLoader::GraphQL.for(type)
as adding type
into a list. Remember, since type
is an instance of a GraphQL type, this is like adding a FeatureType wrapping the Feature with let’s say ID 1 to that list.
Every time for
is called, it adds its argument to that list. So if multiple features ask for their requirements, each feature will be added to that list.
BatchLoader::GraphQL.for(type).batch(key: self) do |records, loader| ...
batch
associates the block that will eventually do the batch load with the list. The block is given two things: the final list of objects and loader
, which is used to tie each set of results to the right object.
By default, batch-loader
uses the source location of the block to group items together into the list. Since the block in this example is used for every field, the block will always have the same source location and that doesn’t work. Instead, key: self
will use source location and the field definition instance (self
) as the key, making sure that each field definition has its own list of items to batch load.
This returns a lazy/proxy object that will eventually act like the record you want.
ActiveRecord::Associations::Preloader.new.preload(records.map(&:object), @preloads)
When the records are finally needed, the block is given all of the FeatureTypes that were passed to for
. Rails' ActiveRecord::Associations::Preloader does the preloading using the specified @preloads
.
records.each { |r| loader.call(r, super(r, args, ctx)) }
Finally, loader.call
links together the correct results with each call. For example, if r
is Feature 1, loader.call
needs to link all of Feature 1's requirements back to Feature 1 so that feature.requirements
will return the correct set from then on. In loader.call
, the first parameter is the same as the item passed into for
and the second is what the return value should be. Here, the correct return value is the same as the default behavior (super
) — this time, with the objects already loaded.
It seems like a lot but that’s all there is to it. And the same pattern can be used for preloading even more complex sets of objects in even more complex ways.
We started building Aha! Develop because we were unsatisfied with the other development tracking tools out there. We wanted something as flexible as the editors we use every day. Something that we, as developers, could make feel like our own. And to do that, we needed a powerful, flexible, fast API. Without it, it would have been impossible to offer the kind of radical customization we promise to developers. graphql-ruby
+ batch-loader
+ a small PreloadableField class made it easier than we ever would have expected and helped us dodge one of the most frequent GraphQL API stereotypes.
If you've also felt that dissatisfaction with other tools, give Aha! Develop a try. Our early access program is open now and there are a limited number of spots available — sign up quickly if you are interested. Learn more about Aha! Develop and how you can request access so your team can start using it: https://www.aha.io/develop/overview