Solution to N+1 problem in Lacinia.
Lacinia is a GraphQL query execution engine that calls resolvers individually for every entity we want to load more data about. This results in a so called N+1 problem, where fetching requested data requires executing 1 query to fetch N items, and then N queries to fetch field information for every item.
This problem can be solved with data loader library like Superlifter. Superlifter can batch data loading requests across all executing queries by using a combination of time windows and batch sizes. This results in a good overall performance at the expense of slightly increased query execution times due to time windows where execution engine waits for a batch to fill.
Plusinia provides an alternative solution for a use case where individual query execution time is more important than overall server performance.
Use git dependency:
io.github.vlaaad/plusinia {:git/tag "v1.23" :git/sha "24e34cd"}
Plusinia works by wrapping Lacinia schema before it's compiled. It sets all query and object resolvers. Thus, it needs fetchers provided for every query and object field. The main difference between Lacinia resolver and Plusinia fetcher is:
- Lacinia resolver transforms input value to result;
- Plusinia fetcher transforms a set of input values to map from input value to result.
Let's start with creating a schema:
(require '[io.github.vlaaad.plusinia :as p]
'[com.walmartlabs.lacinia :as l]
'[com.walmartlabs.lacinia.schema :as l.schema])
(def schema
(l.schema/compile
;; Wrap before compiling:
(p/wrap-schema
;; Lacinia schema:
'{:queries {:people {:type (list :Person)}}
:objects {:Person {:fields {:name {:type String}
:parents {:type (list :Person)}
:children {:type (list :Person)}}}}}
;; Plusinia fetchers. Queries are defined using :Query type
{:Query {:people (fn [_ nils]
(zipmap nils (repeat ["Alice" "Adam" "Beth" "Bob"])))}
:Person {:name (fn [_ people]
(zipmap people people))
:parents (fn [_ people]
(into {}
(map (juxt identity
{"Alice" []
"Adam" []
"Beth" ["Alice" "Adam"]
"Bob" ["Alice" "Adam"]}))
people))
:children (fn [_ people]
(into {}
(map (juxt identity
{"Alice" ["Beth" "Bob"]
"Adam" ["Beth" "Bob"]
"Beth" []
"Bob" []}))
people))}})))
Now that we have a schema, we can query it as usual:
(l/execute schema "
{
people {
name
children {
name
}
parents {
name
}
}
}
" {} {})
=> {:data {:people [{:name "Alice"
:children [{:name "Beth"} {:name "Bob"}]
:parents []}
{:name "Adam"
:children [{:name "Beth"} {:name "Bob"}]
:parents []}
{:name "Beth"
:children []
:parents [{:name "Alice"} {:name "Adam"}]}
{:name "Bob"
:children []
:parents [{:name "Alice"} {:name "Adam"}]}]}}
Fetchers for this query will be executed (sequentially by default) exactly 5 times:
{
people { # batch 1: load a list of people
name # batch 2: for a set of people, fetch names
children { # batch 3: for a set of people, fetch children
name # (batch 5, see below)
}
parents { # batch 4: for a set of people, fetch parents
name # batch 5: for a set of all children and parents, fetch names
}
}
}
You are good to go!
Plusinia uses query introspection capabilities of Lacinia to perform all fetching and batching in the root query resolver. Then, it transforms the results in such a way that resolvers of other fields are simple map lookups.
Even though Plusinia has terms like contexts and types, you shouldn't use any Lacinia code
in Plusinia fetchers: no tag-with-type
, no with-context
, no resolve-promise
.
Fetchers load requested data. Fetchers receive 2 arguments:
- batch key, that defaults to a merge of Plusinia context (
nil
by default) and field or query args; - a set of input values.
Any function is a valid fetcher, but you can also use p/make-field-fetcher
and p/make-query-fetcher
to create fetcher maps that override how batch key is defined for the fetcher, and, in the case of
make-query-fetcher
, also define what context keys from Lacinia context are transferred to Plusinia
context.
For example, if you want to forward a value to fetcher from code that calls l/execute
, you can
do it like that:
(def schema
(l/compile
(p/wrap-schema
...
{:Query {:people (p/make-query-fetcher (fn [{:keys [auth-key]} nils]
...)
:context-keys #{:auth-key})}})))
(l/execute schema "..." {:auth-key ...auth-key-from-http-request})
By default, a fetcher should return a map from input value to result node (or a collection of result nodes), but depending on parallelization strategy it can return anything that will eventually resolve to the result map.
Result node is a result value, that can optionally define GraphQL type and additional context for fetched children of the result value. Any object is a valid node without any explicit type or context.
You can use p/make-node
to define type and/or additional context, e.g.:
[(p/make-node created-event :type :EntityCreated :context {:tx 1})
(p/make-node updated-event :type :EntityUpdated :context {:tx 2})]
Query resolvers in Lacinia are somewhat special because they receive synthetic
nil
as an input value. Plusinia fetchers are similarly special because they
receive a #{nil}
as input values and have to return {nil ...result-node-or-nodes}
.
The boilerplate to execute query fetcher once and then return it for this set with nil
looks like that:
(fn some-query-fetcher [ctx+args nils]
(zipmap nils (repeat (execute-query! ctx+args))))
Plusinia could have used this boilerplate in its implementation to save you some typing, but unfortunately it can't do that due to a pluggable parallelization - fetchers can return futures, or core.async chans, or anything else really!
When wrapping Lacinia schema with p/wrap-schema
, you can provide a function that will
execute a collection of fetch functions to call. It should return a collection of result
maps in the same order. Default implementation looks like that:
(fn [fs]
(mapv #(%) fs))
You can make your fetchers return futures, and then make your parallelization function deref them, e.g.:
(p/wrap-schema ...schema ...fetchers :execute-batches (fn [fs]
(mapv deref (mapv #(%) fs))))
Or you can make your fetchers return result maps and invoke them in a thread pool:
(import '[java.util.concurrent Executors ExecutorService])
(def ^ExecutorService pool
(Executors/newFixedThreadPool 32))
(p/wrap-schema ...schema ...fetchers :execute-batches #(mapv deref (.invokeAll pool %)))
Plusinia batches calls to fetchers on:
- field fetcher function;
- batch key (defaults to a merge of context and args);
- nesting depth of the query.
1st arg to fetcher function is a batch key. By default, this is a merge of Plusinia
context (that defaults to nil
) and field args. You can override the batch key on
per-field basis by providing a custom batch function, e.g.:
{:Shape {:area (p/make-field-fetcher (fn [shape-type shapes]
(case shape-type
...))
:batch-fn (fn [_ctx _args value]
(:type value)))}}
Plusinia supports interfaces and unions. It also allows specifying fetchers on the abstract type - both unions and interfaces. In this case, fetchers will be shared between concrete types. Overriding is supported. Example:
;; You'll probably need this in your project for simple "getter" fetchers :)
(defn- transform-value-fetcher [f]
(fn [_ xs]
(into {} (map (juxt identity f)) xs)))
(def schema
(l.schema/compile
(p/wrap-schema
'{:queries {:changes {:type (list :Event)}}
:interfaces {:Event {:fields {:id {:type String}}}}
:objects {:EntityCreated {:implements [:Event]
:fields {:id {:type String}
:createdAt {:type String}}}
:EntityUpdated {:implements [:Event]
:fields {:id {:type String}
:updatedAt {:type String}}}}}
{:Query {:changes (fn [_ nils]
;; Queries of abstract types have to return explicitly typed nodes:
(zipmap
nils
(repeat [(p/make-node {:id 1 :createdAt "2022-04-14"} :type :EntityCreated)
(p/make-node {:id 1 :updatedAt "2022-04-15"} :type :EntityUpdated)
(p/make-node {:id 2 :createdAt "2022-04-16"} :type :EntityCreated)])))}
;; Fetcher of abstract type:
:Event {:id (transform-value-fetcher :id)}
;; Fetchers of concrete types:
:EntityCreated {:createdAt (transform-value-fetcher :createdAt)}
:EntityUpdated {:updatedAt (transform-value-fetcher :updatedAt)}})))
(l/execute schema "
{
changes {
event: __typename
id
... on EntityCreated {
createdAt
}
}
}
" {} {})
=> {:data {:changes [{:event :EntityCreated
:id "1"
:createdAt "2022-04-14"}
{:event :EntityUpdated
:id "1"}
{:event :EntityCreated
:id "2"
:createdAt "2022-04-16"}]}}
That's all, folks!
If you are using this in production, please consider sponsoring my work on Plusinia.