aiodataloader

repository·master·Indexed 43 days ago

https://github.com/syrusakbary/aiodataloader

An AsyncIO-based implementation of the DataLoader pattern for Python. It optimizes data fetching by batching multiple individual requests into a single call and providing per-request memoization caching, making it particularly effective for preventing the N+1 query problem in GraphQL environments.

Tokens
3.6K
Snippets
12
Records
15
Agent score
5%

What's inside aiodataloader

  1. How DataLoader batching works

    master

    Batching is the primary feature of DataLoader. It coalesces multiple individual .load() calls that occur within a single tick of the event loop into a single call to your batch_load_fn.

    To implement batching, subclass DataLoader and provide a batch_load_fn. This function must adhere to two strict constraints:

    1. The returned list of values must be the same length as the input list of keys.
    2. The index of each value in the list must correspond exactly to the index of its key in the input list.

    If a key has no corresponding value in the backend, you must return None at that index.

    from aiodataloader import DataLoader
    
    class UserLoader(DataLoader):
        async def batch_load_fn(self, keys):
            # keys: [2, 9, 6, 1]
            # returns: [{id: 2...}, {id: 9...}, None, {id: 1...}]
            return await my_batch_get_users(keys)
    
    user_loader = UserLoader()
    
    # These two calls are coalesced into one batch call
    user1_future = user_loader.load(1)
    user2_future = user_loader.load(2)
    
    user1 = await user1_future
    user2 = await user2_future
  2. How DataLoader caching works

    master

    DataLoader provides an in-memory memoization cache. Once .load(key) is called, the resulting value is cached. Subsequent calls with the same key return the same Future/value without re-triggering the batch function.

    Caching per-Request Pattern

    DataLoader is intended for per-request caching, not shared application-level caching (like Redis). To avoid data leakage between users (e.g., one user seeing another's private data), you should create a new DataLoader instance at the start of every web request and discard it when the request ends.

    Clearing the Cache

    If a mutation (like a SQL UPDATE) occurs during a request, the cached value may become stale. Use .clear(key) to invalidate a specific key or .clear_all() to wipe the entire cache.

    # Example of invalidating a specific key after a mutation
    user4 = await user_loader.load(4)
    
    # Mutation occurs
    await sql_run('UPDATE users WHERE id=4 SET username="zuck"')
    
    # Invalidate the stale cache entry
    user_loader.clear(4)
    
    # Next load will fetch fresh data
    user4 = await user_loader.load(4)
  3. Disable caching in DataLoader

    master

    If you need to ensure every .load() call results in a new fetch, set cache=False in the constructor or as a class attribute.

    Warning: When cache=False, your batch_load_fn may receive duplicate keys in a single batch if .load() was called multiple times for the same key within the same event loop tick. Your batch function must be able to handle these duplicates.

    class MyLoader(DataLoader):
        cache = False
        async def batch_load_fn(self, keys):
            # If .load('A') was called twice, keys might be ['A', 'B', 'A']
            return keys
    
    my_loader = MyLoader()
    my_loader.load('A')
    my_loader.load('B')
    my_loader.load('A')
  4. Loading by alternative keys using prime()

    master

    If a resource can be accessed via different keys (e.g., an id or a username), you can use the .prime(key, value) method to manually populate the cache of one loader from the results of another. This ensures that if a user is loaded by ID, they are also immediately available in the cache when searched by username, avoiding redundant batch requests.

    async def user_by_id_batch_fn(ids):
        users = await gen_users_by_id(ids)
        for user in users:
            username_loader.prime(user.username, user)
        return users
    
    user_by_id_loader = DataLoader(user_by_id_batch_fn)
    
    async def username_batch_fn(names):
        users = await gen_usernames(names)
        for user in users:
            user_by_id_loader.prime(user.id, user)
        return users
    
    username_loader = DataLoader(username_batch_fn)
  5. Create a new DataLoader per request

    master

    To ensure security and data isolation between different users (e.g., in a web server), it is recommended to create a new instance of DataLoader for every incoming request. This prevents one user's cached data from being served to another user.

    A common pattern is to return a dictionary of loaders that can be passed through the application context or root_value.

    def create_loaders(auth_token):
      return {
        'users': DataLoader(lambda ids: gen_users(auth_token, ids)),
        'cdn_urls': DataLoader(lambda raw_urls: gen_cdn_urls(auth_token, raw_urls)),
        'stories': DataLoader(lambda keys: gen_stories(auth_token, keys)),
      }
    
    # When handling an incoming web request:
    loaders = create_loaders(request.query.auth_token)
    
    # Then, within application logic:
    user = await loaders.users.load(4)
  6. Using DataLoader with GraphQL

    master

    DataLoader is highly effective in GraphQL environments to prevent the N+1 query problem. Instead of each field resolver issuing a separate database request, you can use load() for single items and load_many() for lists. This collapses multiple field resolutions into a single batch request.

    When using graphene, you can implement resolvers that call the loader instead of querying the database directly.

    class User(graphene.ObjectType):
        name = graphene.String()
        best_friend = graphene.Field(lambda: User)
        friends = graphene.List(lambda: User)
    
        def resolve_best_friend(self, args, context, info):
            return user_loader.load(self.best_friend_id)
    
        def resolve_friends(self, args, context, info):
            return user_loader.load_many(self.friend_ids)
  7. Configure custom caches

    master

    By default, DataLoader uses a standard Python dict for its memoization cache, which grows indefinitely until the loader is released.

    You can provide a custom cache object during initialization to implement specific cache eviction algorithms (like LRU). The custom object must implement the following methods:

    • get(key)
    • set(key, value)
    • delete(key)
    • clear()
  8. How DataLoader batching and caching work together

    master

    The DataLoader optimizes data fetching through two main mechanisms:

    1. Batching: When batch=True, calling load(key) does not immediately trigger the batch_load_fn. Instead, it enqueues the request. The loader schedules a dispatch using loop.call_soon, which allows multiple load() calls in the same event loop tick to be grouped into a single call to batch_load_fn.
    2. Caching: When cache=True, the loader stores the Future returned by load(key) in an internal map. Subsequent calls with the same key return the same Future instance. This ensures that even if multiple requests for the same key are made before the first batch completes, they all await the same result.

    Batching Constraints:

    • If max_batch_size is set, the loader will split the queue into chunks of that size and trigger multiple batch_load_fn calls if necessary.
    • The batch_load_fn must return an iterable of the same length as the input keys. If the lengths mismatch, a TypeError is raised and the batch is failed.
  9. Reference: DataLoader public API

    master

    The DataLoader class provides the following methods for interacting with the data layer:

    • DataLoader(batch_load_fn, **options): Constructor.
    • load(key): Returns a Future for the value represented by key.
    • load_many(keys): Returns a list of values for the provided list of keys. Equivalent to await asyncio.gather(*[loader.load(k) for k in keys]).
    • clear(key): Clears the value at key from the cache. Returns self for chaining.
    • clear_all(): Clears the entire cache. Returns self for chaining.
    • prime(key, value): Manually adds a key and value to the cache. If the key exists, no change is made. To overwrite, use loader.clear(key).prime(key, value). Returns self for chaining.
    # Using load_many
    a, b = await my_loader.load_many(['a', 'b'])
    
    # Using prime for manual cache injection
    my_loader.prime('existing_key', 'some_value')
  10. Reference: DataLoader constructor options

    master

    When initializing DataLoader(batch_load_fn, **options), you can provide the following configuration keys:

    OptionTypeDefaultDescription
    batchboolTrueSet to False to disable batching; batch_load_fn will be called immediately for every single key.
    max_batch_sizeint/floatInfinityLimits the number of items passed to batch_load_fn in a single call.
    cacheboolTrueSet to False to disable memoization caching.
    cache_key_fnfunctionkey => keyA function to produce a cache key for a given load key. Useful for custom equality logic.
    cache_mapdict{}An instance of dict (or similar) to use as the underlying cache storage.
  11. Initialize the DataLoader class

    master

    To use aiodataloader, instantiate the DataLoader class by providing a batch_load_fn. This function must be a coroutine that accepts a list of keys and returns a list of values (or a Future/Coroutine resolving to a list of values) of the same length.

    Parameters:

    • batch_load_fn: (Required) A coroutine function: Callable[[List[KeyT]], Coroutine[Any, Any, List[ReturnT]]].
    • batch: (Optional) Boolean. If True (default), keys are collected and loaded in batches. If False, each load() call triggers an immediate dispatch.
    • max_batch_size: (Optional) Integer. Limits the number of keys sent to batch_load_fn in a single call. If the queue exceeds this, it is split into multiple chunks.
    • cache: (Optional) Boolean. Enables or disables the internal cache (default is True).
    • get_cache_key: (Optional) A function to transform a key into a cache key.
    • cache_map: (Optional) An existing mapping to use as the cache.
    • loop: (Optional) An asyncio.AbstractEventLoop instance.
    from aiodataloader import DataLoader
    
    async def my_batch_loader(keys):
        # Your logic to fetch data for multiple keys at once
        return [f"value_for_{k}" for k in keys]
    
    dataloader = DataLoader(batch_load_fn=my_batch_loader)