You seem to resolve each type individually, wouldn’t it be more easy to simply translate between GraphQL and GraphQL± ?
Easier? no, I don’t think so. I’d need to write a whole GraphQL parser & execution engine to be able to translate GQL directly to GQL± which would also allow me to do authorization, validation, etc. It’s an interesting question though which I’ve asked before writing this tech-demo, yet I couldn’t find an answer.
Maybe you’ve got a simpler approach in mind that I’ve missed out?
Is this doing a request against dgraph for each node in your graphql request tree
Not exactly (leaf data nodes are fetched together), but yes. The resolvers are implemented naively so far because there’s no batching and caching involved yet. v1.0 was intended to just work but optimizations are necessary to make it production-ready.
query {
users { // -> 1 roundtrip
id
displayName
posts { // -> u roundtrips
id
title
contents
reactions { // -> r roundtrips
id
emotion
message
author { // -> r roundtrip
id
displayName
}
}
}
}
}
This query would, indeed, result in 1+u+(r*2)
database roundtrips where u
is the total number of users and r
is the number of relevant reactions. Assuming there are 100 users, each having 100 posts each having 100 reactions this single query would invoke 1+100+100*100*2 = 20101
database roundtrips, which, of course, is terribly inefficient!
There are 4 things we can/should do:
- Allow only whitelisted, safe queries to be executed by clients.
- Implement pagination such that requesting the entire lists (
Query.users
, User.posts
, Post.reactions
) isn’t allowed.
- Introduce batching.
- Introduce caching.
Batching & caching will cut down the number of requests significantly. Take the User.posts resolver for example, instead of performing an actual database request right in the resolver we should request it from a loader:
// Posts resolves User.posts
func (rsv *User) Posts(
ctx context.Context,
) ([]*Post, error) {
posts, err := rsv.root.loader.UserPosts(ctx, rsv.uid)
if err != nil {
return nil, err
}
if len(posts) < 1 {
return nil, nil
}
resolvers := make([]*Post, len(posts))
for i, post := range posts {
resolvers[i] = &Post{
root: rsv.root,
uid: post.UID,
id: post.ID,
creation: post.Creation,
title: post.Title,
contents: post.Contents,
authorUID: rsv.uid,
}
}
return resolvers, nil
}
The loader can then do batching & caching internally.
Batching would accumulate the uids and fire a single batched query against the database when either the batch size or the time out (~5-10ms) is reached. With batching enabled, the number of database roundtrips in the example above would be reduced to only 4!
Caching would eliminate redundant requests. Why load all posts of the user x
if we already did it before and have them in the cache? Caching, however, comes with some problems. It works nicely on a single machine but when we start scaling out horizontally to more than 1 API servers we’ll end up having a distributed cache invalidation problem when a user creates/removes a post. It can be addressed this way:
- TTL (time-to-live) = availability
- Internal event broadcasting = consistency (but then there also are net splits we have to deal with)
- Hybrid (TTL + event broadcasting)
If request / node is true, how did you solve the n+1 issue?
I didn’t, yet . The n+1 problem is solved using the data loaders described above.
If you did solve the n+1 issue, how did you solve it on request nodes that require filtering? (what you would typically solve with @filter() using GraphQL±)
It depends on what filter you mean. For every static filter there’d be a loader. Let’s say we have two different lists:
type User {
# all published posts
posts: [Post!]!
# all archived posts
archivedPosts: [Post!]!
}
Those nodes would need to be resolved by separate loaders:
User.posts -> loader.UserPosts(userUID)
User.archivedPosts -> loader.UserArchivedPosts(userUID)
.
And this query would result in 3 database roundtrips
query {
user(id: "x") { // -> 1 roundtrip
posts { // -> 1 roundtrip
id
}
archivedPosts { // -> 1 roundtrip
id
}
}
}
But I doubt you can do the same with dynamic filters like these:
type User {
# allows arbitrary user-defined queries "-reactions:>5 -creation:yesterday"
posts(query: String!): [Post!]!
# allows to look for posts with a similar title
postsWhereTitleLike(title: String!): [Post!]!
}
With dynamic filters based on arguments batching is out of question I suppose, you could cache the results though.