Sep 6, 2021

Engineering

GraphQL and Pagination in Backend.AI

  • Kangmin Kwon

    Kangmin Kwon

    Software Engineer

Sep 6, 2021

Engineering

GraphQL and Pagination in Backend.AI

  • Kangmin Kwon

    Kangmin Kwon

    Software Engineer

At Backend.AI, we use GraphQL. In this post, we're going to talk about three main topics. First, we'll give a brief explanation of GraphQL and how we use it. Second, we'll discuss the technical challenges we faced while using GraphQL and how we solved them. Finally, we'll take some time to explain Pagination.

GraphQL Overview

GraphQL is a query language created by Facebook. A Facebook page displays a wide variety of information, such as personal info, timelines, and notification messages. If you were to query each of these individually, multiple connections would be created for each query. Fetching all of this with a JSON REST API would result in a very high number of round trips (leading to problems like over-fetching, under-fetching, and managing numerous REST endpoints)1. GraphQL helps create faster and lighter web applications by overcoming these shortcomings of REST APIs.

How Does Lablup Use GraphQL?

At Backend.AI, we use GraphQL not only for the advantage of reducing round-trip calls mentioned earlier, but also because it allows for flexible implementation of resolvers. This flexibility can be explained in two ways. First, for fields that existed in an older version of an object schema but have been removed in a new version, we can use a separate resolver method after object creation to populate the legacy field content using data from the current version's object. Second, it allows us to combine fields belonging to a single object from different data sources to provide a consistent data model.

As an example of the first point, if you add a separate resolver for a field that has been removed in a new version, like the resolve_legacy_compute_session() method below, you can populate the legacy table's content using data from the current version's table. This makes it easier to support backward compatibility even as versions are updated and new fields or object types are added.

async def resolve_legacy_compute_session( executor: AsyncioExecutor, info: graphene.ResolveInfo, sess_id: str, *, domain_name: str = None, access_key: AccessKey = None, status: str = None, ) -> Optional[LegacyComputeSession]: graph_ctx: GraphQueryContext = info.context loader = graph_ctx.dataloader_manager.get_loader( graph_ctx, 'LegacyComputeSession.detail', domain_name=domain_name, access_key=access_key, status=status ) matches = await loader.load(sess_id) if len(matches) == 0: return None elif len(matches) == 1: return matches[0] else: raise TooManyKernelsFound

As a second example, we designed our system so that row objects fetched from the DB via a query are converted using the from_row() method as shown below, so that the GraphQL engine can understand them.

@classmethod def from_row( cls, ctx: GraphQueryContext, row: Row, ) -> Agent: mega = 2 ** 20 return cls( id=row['id'], status=row['status'].name, status_changed=row['status_changed'], region=row['region'], ... # legacy fields mem_slots=BinarySize.from_str(row['available_slots']['mem']) // mega, cpu_slots=row['available_slots']['cpu'], gpu_slots=row['available_slots'].get('cuda.device', 0), tpu_slots=row['available_slots'].get('tpu.device', 0), ... )

In cases where a field is defined in the object but cannot be fully populated during the conversion, a separate resolver like the resolve_live_stat() method below can be used to fetch the data from Redis instead of the DB. Therefore, although fields may belong to the same object in the GraphQL schema, their values can come from the DB or from Redis. In summary, you can implement a conceptual data model in GraphQL, and the actual data that populates this model can be fetched from various data sources (like a SQL database or Redis).

async def resolve_live_stat(self, info: graphene.ResolveInfo) -> Any: ctx: GraphQueryContext = info.context rs = ctx.redis_stat live_stat = await redis.execute_with_retries( lambda: rs.get(str(self.id), encoding=None) ) if live_stat is not None: live_stat = msgpack.unpackb(live_stat) return live_stat

The Problem of Inefficient Database Queries with GraphQL and How to Solve It Through Batching2

For example, if you want to fetch data for 10 users and you write a SQL query inside a GraphQL resolver, you will end up sending 10 separate queries. This might not be a problem on a small scale, but it becomes very inefficient as the number of queries increases. To solve this, we use a library called DataLoader. When DataLoader resolves the same GraphQL object multiple times (e.g., passing 10 different user.ids as arguments to the same resolve function), it doesn't process the queries individually. It groups the input arguments (in this case, user.id) into a list and then processes them in batches. Of course, the developer can decide the size of the batch (at Backend.AI, we process them in batches of 128). Consequently, the WHERE clause of the resolver's query statement changes as follows.

WHERE users.id == $user_id
WHERE users.id IN $user_ids

Thus, the DataLoader library has the effect of batching SQL queries. Since it can reduce the various tasks a database has to perform for each individual SQL query (like parsing the SQL statement and creating a query plan) to a single operation, making good use of this library reduces the number of round trips to the actual SQL database. While GraphQL reduces the number of round trips between the client and the API server, if queries are not batched, the number of round trips increases with the number of queries, offsetting the advantages of GraphQL. In Backend.AI's case, we use a library that wraps Graphene, a GraphQL engine for Python. Since Graphene supports both synchronous and asynchronous operations, we use aiodataloader for batching in our asynchronous resolvers.

To maximize the batching effect by reusing the same DataLoader for the same resolver within a single API request as much as possible, we made additional optimizations to the DataLoader library. Specifically, it ensures that the same dataloader is consistently reused even when multiple GraphQL queries are sent in a single API request, or when the same type of object is accessed from different branches of nested query trees. For optimization, Backend.AI created a class called DataLoaderManager and designed it to create one DataLoaderManager object per API request and cache it for repeated calls.

class DataLoaderManager: cache: Dict[int, DataLoader] def __init__(self) -> None: self.cache = {} self.mod = sys.modules['ai.backend.manager.models'] @staticmethod def _get_key(otname: str, args, kwargs) -> int: key = (otname, ) + args for item in kwargs.items(): key += item return hash(key) def get_loader(self, context: GraphQueryContext, objtype_name: str, *args, **kwargs) -> DataLoader: k = self._get_key(objtype_name, args, kwargs) loader = self.cache.get(k) if loader is None: objtype_name, has_variant, variant_name = objtype_name.partition('.') objtype = getattr(self.mod, objtype_name) if has_variant: batch_load_fn = getattr(objtype, 'batch_load_' + variant_name) else: batch_load_fn = objtype.batch_load loader = DataLoader( apartial(batch_load_fn, context, *args, **kwargs), max_batch_size=128, ) self.cache[k] = loader return loader

Pagination

Although the official GraphQL documentation describes a recommended method for implementing pagination3, it's not a mandatory standard, so the rules differ for each implementation. We adopted a method using limit and offset. If you have 10-20 objects, you can fetch them all at once, but when the number grows to hundreds or thousands, the load on the DB becomes significant, which is why we use pagination. When we initially implemented Backend.AI with GraphQL, we used graphene.List without pagination and just added appropriate filter conditions. We sent the SQL query all at once using a method called load_all() to fetch the data. However, as the number of agents increased to dozens and cloud users to thousands, the need for pagination arose. To address this, we defined a new object type called AgentList, which follows the PaginatedList interface.

class AgentList(graphene.ObjectType): class Meta: interfaces = (PaginatedList, ) items = graphene.List(Agent, required=True)
class Item(graphene.Interface): id = graphene.ID() class PaginatedList(graphene.Interface): items = graphene.List(Item, required=True) total_count = graphene.Int(required=True)

We created the AgentList object type and also implemented its resolver. resolve_agent_list() takes limit, offset, filter, and order as common arguments. limit and offset have the same meaning as in SQL syntax for limiting the range of query results, while filter and order are expressions that allow specifying conditions for arbitrary fields. total_count signifies the total number of agents matching the conditions, obtained via the load_count() function, and agent_list refers to the populated list of agents fetched according to the limit and offset conditions via the load_slice() function.

async def resolve_agent_list( executor: AsyncioExecutor, info: graphene.ResolveInfo, limit: int, offset: int, *, filter: str = None, order: str = None, scaling_group: str = None, status: str = None, ) -> AgentList: total_count = await Agent.load_count( info.context, scaling_group=scaling_group, raw_status=status, filter=filter, ) agent_list = await Agent.load_slice( info.context, limit, offset, scaling_group=scaling_group, raw_status=status, filter=filter, order=order, ) return AgentList(agent_list, total_count)

Summary

In this post, we have discussed the following topics:

  • An overview of GraphQL
  • How we use GraphQL at Backend.AI
  • Problems when using GraphQL and how to solve them with batching
  • The need for Pagination and how to implement it

We hope this article serves as a helpful reference for those looking to adopt and optimize GraphQL.

Footnotes

  1. https://www.howtographql.com/basics/1-graphql-is-the-better-rest/

  2. Batching refers to the task of processing requests in a bundle, rather than processing them as soon as they arrive.

  3. https://graphql.org/learn/pagination/

We're here for you!

Complete the form and we'll be in touch soon

Contact Us
lablup

Headquarter & HPC Lab

KR Office: 8F, 577, Seolleung-ro, Gangnam-gu, Seoul, 06143, Republic of Korea US Office: 3003 N First st, Suite 221, San Jose, CA 95134

  • facebook
  • youtube
  • Linkedin
  • GitHub

© Lablup Inc. All rights reserved.

We value your privacy

We use cookies to analyze site traffic, understand how visitors use our website, and improve our services. Necessary cookies for basic site functions are always active. Learn more

By clicking "Accept All", you agree to the storage of analytics cookies on your device. Click "Reject All" to keep only necessary cookies, or "Customize" to choose for yourself. You can change your settings at any time.