Skip to content

@Loader() invocations do not batch properly within a GQL federation context #29

@Blacksmoke16

Description

@Blacksmoke16

Summary

This issue is a bit in the weeds, but I'll do my best to give some background context. We're making use of GQL federation to stitch together a few sub-graphs. For purposes of this example say we have two. A parent that has the majority of the fields, then another sub-graph that has a specific field say some_relationship. To handle the specific field, we defined a NestJS resolver class that has a method with the @ResolveField directive, which it properly stitches it into the primary User type.

Given a query like:

{
  users(where: {id: {_in: [10, 13]}}) {
    id
    full_name
    some_relationship {
      id
    }
  }
}

I noticed that a SQL query was being made twice, once for each ID when I would have expected the dataloader to batch them together. I think it has something to do with some race condition where concurrent requests get different DataLoader instances.

Here's an example test case that better illustrates the issue. I needed to install @nestjs/apollo, @apollo/server, @as-integrations/express5 as dev deps to make this work.

import request from "supertest";
import { describe } from "vitest";
import { Injectable } from "@nestjs/common";
import { ApolloDriver, type ApolloDriverConfig } from "@nestjs/apollo";
import { Field, GraphQLModule, Int, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql";
import { type NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { DataloaderFactory, DataloaderModule, Loader, type LoaderFrom } from "@strv/nestjs-dataloader";

@ObjectType()
class Item {
  @Field(() => Int) id!: number;
  @Field() name!: string;
}

@ObjectType()
class ParentType {
  @Field(() => Int) id!: number;
}

@Injectable()
class ItemsLoaderFactory extends DataloaderFactory<number, Item> {
  static calls: number[][] = [];

  load = async (ids: number[]) => {
    ItemsLoaderFactory.calls.push(ids);
    return ids.map((id) => ({ id, name: `item-${id}` }));
  };

  id = (item: Item) => item.id;
}

@Resolver(() => ParentType)
class ParentResolver {
  @Query(() => [ParentType])
  parents() {
    return [{ id: 1 }, { id: 2 }];
  }

  @ResolveField(() => Item)
  async item(@Parent() parent: ParentType, @Loader(ItemsLoaderFactory) loader: LoaderFrom<ItemsLoaderFactory>) {
    return await loader.load(parent.id);
  }
}

describe("@Loader() under concurrent field resolution", (it) => {
  it("batches sibling field resolutions into a single factory call", async (t) => {
    ItemsLoaderFactory.calls = [];

    const module = await Test.createTestingModule({
      imports: [
        DataloaderModule.forRoot(),
        GraphQLModule.forRoot<ApolloDriverConfig>({
          driver: ApolloDriver,
          autoSchemaFile: true,
        }),
      ],
      providers: [ItemsLoaderFactory, ParentResolver],
    }).compile();

    const app = await module.createNestApplication<NestExpressApplication>().init();
    t.onTestFinished(async () => await app.close());

    const response = await request(app.getHttpServer())
      .post("/graphql")
      .send({ query: "{ parents { id item { id name } } }" });

    t.expect(response.status).toBe(200);
    t.expect(response.body.errors).toBeUndefined();
    t.expect(response.body.data).toEqual({
      parents: [
        { id: 1, item: { id: 1, name: "item-1" } },
        { id: 2, item: { id: 2, name: "item-2" } },
      ],
    });
    t.expect(ItemsLoaderFactory.calls).toEqual([[1, 2]]);
  });
});

Where the output is:

 FAIL  test/FieldResolverBatching.test.ts > @Loader() under concurrent field resolution > batches sibling field resolutions into a single factory call
AssertionError: expected [ [ 1 ], [ 2 ] ] to deeply equal [ [ 1, 2 ] ]

- Expected
+ Received

  [
    [
      1,
+   ],
+   [
      2,
    ],
  ]

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions