In this article, we are going to understand and implement the DataLoader concept in Hot Chocolate GraphQL.
This error because in the fragment query we are requested 2 comparison results, since our resolver is an asynchronous resolver so 2 requests are executed parallel and both are trying to access the database context at the same time.
From the above 2 images, we can observe resolver executed 2 times.
Here we will use an existing Hot Chocolate sample application that was created in the introduction articles on Hot chocolate GraphQL. I recommend reading those articles:
Part-1 Introduction On Hot Chocolate GraphQL
Part-1 Introduction On Hot Chocolate GraphQL
Issues Need To Fix:
Before implementing the DataLoader into our sample we must aware of the issues we are facing. In GrphQL we have an option like Fragments Query. Fragments query is used to display the comparison results very effective way.
Fragments Query:
query{ Gadget1:GetByBrand(brand:"samsung"){ Id ProductName Brand } Gadget2:GetByBrand(brand:"red mi"){ Id ProductName Brand } }Here we request a GraphQL endpoint to serve the different gadgets for comparison this type of query called Fragments Query.
Now this kind of Fragments Query produces issues like:
- We will face a threading issue with the database context on using the async database communication calls.
- In a normal process, this Fragments Query will run the query resolver method(logic that contains database communication) count is like based on n-number of comparison queries. This means if you observe the above sample query we requesting 2 comparison results so the revolver method will execute 2 times(which means 2 database calls). This might leads to performance issues.
Async Calls Database Context Issue:
Let's expose ourselves to the kind of issue we will get using the asynchronous calls.
In Part-2 written a resolver method as synchronous like below.
SchemaCore/Query.cs:(Existing Logic)
public List<Gadgets> GetByBrand(string brand, [Service] MyWorldDbContext context) { return context.Gadgets.Where(_ => _.Brand.ToLower() == brand.ToLower()).ToList(); }The results look as below.
Now let's update our resolver to an asynchronous approach.
SchemaCore/Query.cs:(Existing Logic)
public async Task<List<Gadgets>> GetByBrand(string brand, [Service] MyWorldDbContext context) { return await context.Gadgets .Where(_ => _.Brand.ToLower() == brand.ToLower()).ToListAsync(); }Let's check the results for the asynchronous calls.
You can see we are getting errors in the result set. But the actual error is like this:-
An exception occurred while iterating over the results of a query for contexttype'HC.GraphQL.Sample.Data.Context.MyWorldDbContext'.
System.InvalidOperationException: A second operation was started in this context before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext.
This error because in the fragment query we are requested 2 comparison results, since our resolver is an asynchronous resolver so 2 requests are executed parallel and both are trying to access the database context at the same time.
Use Database Context Pool To Resolve The Threading Issue:
In .net DbContext pool keeps by default 128 Dbcontext alive in the pool. So that these instances will be served to requests without destroying them immediately. So if our fragment request will use separate DBContext for each thread request.
Existing DbContext registration looks like as below:
Startup.cs:(Existing Code)
services.AddDbContext<MyWorldDbContext>(options => { options.UseSqlServer(Configuration.GetConnectionString("MyWorldDbConnection")); });Now let's update our code to register the DbContext pool as below:
Startup.cs:(Updated Code)
services.AddPooledDbContextFactory<MyWorldDbContext>(options => { options.UseSqlServer(Configuration.GetConnectionString("MyWorldDbConnection")); });Now let's implement our logic to fetch the DbContext instance from the pool of DbContext. So let's create an extension method of the IObjectFieldDescriptor that will generate DbContext as below.
Extensions/ObjectFieldDescriptorExtensions:
using HotChocolate.Types; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace HC.GraphQL.Sample.Extensions { public static class ObjectFieldDescriptorExtensions { public static IObjectFieldDescriptor UseDbContext<TDbContext>( this IObjectFieldDescriptor descriptor ) where TDbContext: DbContext { return descriptor.UseScopedService<TDbContext>( create: s => s.GetRequiredService<IDbContextFactory<TDbContext>>().CreateDbContext(), disposeAsync: (s,c) => c.DisposeAsync() ); } } }
- (Line: 14) Fetching the context from the pool.
- (Line: 15) Releases the allocated resource to DbContext.
Attributes/UseMyWordDbContextAttribute.cs:
using System.Reflection; using HC.GraphQL.Sample.Data.Context; using HotChocolate.Types; using HotChocolate.Types.Descriptors; namespace HC.GraphQL.Sample.Extensions { public class UseMyWorldDbContextAttribute : ObjectFieldDescriptorAttribute { public override void OnConfigure(IDescriptorContext context, IObjectFieldDescriptor descriptor, MemberInfo member) { descriptor.UseDbContext<MyWorldDbContext>(); } } }
- (Line: 8) Inherits from the 'HotChocolate.Types.ObjectFieldDescriptorAttribute'.
- (Line: 13) Above create generic extension method registered here with our database context.
Schemas/Query.cs:
[UseMyWorldDbContext] public List<Gadgets> GetByBrand(string brand, [ScopedService] MyWorldDbContext context) { return context.Gadgets.Where(_ => _.Brand.ToLower() == brand.ToLower()).ToList(); }
- (Line: 1)Decorated our 'UseMyWorldDbContext' attribute
- (Line: 2) Used the 'ScopedService' attribute to inject the Dbcontext into our resolver method.
Fragment Query Multiple Database Calls Issue:
As mentioned that fragment query will run the resolver method multiple times based on the number of comparison queries inside of it. For the above sample query or screenshots, we used only 2 comparison queries inside of the fragment query so this will invoke the resolver method 2 times which might be performance issues.
From the above 2 images, we can observe resolver executed 2 times.
DataLoader To Fix Multiple Database Calls Issue:
DataLoader technique in GraphQL to fetch the fragments of data with a single execution of the resolver method which gives us performance benifits.
Let's implement a DataLoader for our sample.
DataLoader/GadgetsByBrandDataLoader.cs:
using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using HC.GraphQL.Sample.Data.Context; using HC.GraphQL.Sample.Data.Entities; using HotChocolate.DataLoader; using HotChocolate.Fetching; using Microsoft.EntityFrameworkCore; namespace HC.GraphQL.Sample.DataLoader { public class GadgetsByBrandDataLoader : BatchDataLoader<string, List<Gadgets>> { private readonly IDbContextFactory<MyWorldDbContext> _dbContextFactory; public GadgetsByBrandDataLoader(IDbContextFactory<MyWorldDbContext> dbContextFactory, BatchScheduler batchScheduler) : base(batchScheduler) { _dbContextFactory = dbContextFactory; } protected override async Task<IReadOnlyDictionary<string, List<Gadgets>>> LoadBatchAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken) { await using (MyWorldDbContext dbContext = _dbContextFactory.CreateDbContext()) { var gadgets = await dbContext.Gadgets .Where(g => keys.Select(_ => _.ToLower()).ToList().Contains(g.Brand)).ToListAsync(cancellationToken); var result = gadgets.GroupBy( _ => _.Brand.ToLower()) .Select(_ =>new { key = _.Key.ToLower(), gadgets = _.ToList() }).ToDictionary(_ => _.key, _ => _.gadgets); return result; }; } } }
- (Line: 13) To create a HotChocolate GraphQL Dataloader we need to inherit base class DataLoader that is 'HotChocolate.DataLoader.BatchDataLoader<TKey,TValue>'. Here 'TKey' is the type of our query parameter value and 'TValue' is the output type returned from the delivery.
- (Line: 16&17) Injected 'HotChocolate.Fetching.BatchScheduler' and then its value passed to the base constructor. Also injected 'Microsoft.EntityFrameworkCore.IDbContextFactory'.
- (Line: 21) Implemented the 'LoadBatchAsync' method whose return type is key-value pair dictionary.
- This method has input parameters is like collection string as the variable name 'keys', all these collections of string nothing but our query parameters from each comparison query this helps to fetch the data from the database with a single attempt.
- (Line: 25-31) Fetching results based on the input parameter collection 'keys' and return the result as dictionary data.
Startup.cs:
services.AddGraphQLServer() .AddQueryType<QueryObjectType>() .AddMutationType<MutationObjectType>() .AddDataLoader<GadgetsByBrandDataLoader>();Now let's implement our resolver method where we are going to use our DataLoader to output the results.
SchemaCore/Query.cs:
public async Task<List<Gadgets>> GetByBrandLoader( string brand, GadgetsByBrandDataLoader dataLoader, CancellationToken cancellationToken) { var result = await dataLoader.LoadAsync(brand,cancellationToken); return result; }
- Here for our resolver first parameter is our query parameter and the second is our Dataloader passed as a parameter this will be passed by the framework automatically.
ObjectTypes/QueryObjectType.cs:
descriptor.Field(g => g.GetByBrandLoader(default, default, default)).Type<ListType<GadgetsObjectType>>() Name("GetByBrandLoader");Now let's test our fragments query that will return the result with a single time execution of the resolver method with the help of DataLoader.That's all about the DataLoader in the HotChocolate GraphQL.
Support Me!
Buy Me A Coffee
PayPal Me
Wrapping Up:
Hopefully, I think this article delivered some useful information about DataLoader in GraphQL. I love to have your feedback, suggestions, and better techniques in the comment section below.
Nice article, but I don't see the difference between the existing and the updated DbContext registration. Am I missing something?
ReplyDeleteThe updated DbContext registration should be this (I think, the author copied the code but forgot to change the method name):
ReplyDeleteservices.AddPooledDbContextFactory(options => {
options.UseSqlServer(Configuration.GetConnectionString("MyWorldDbConnection"));
});
The article itself is awesome, thanks for it.
thanks alot
Deletecorrected now
good stuff! thanks but the code was going off the screenand i had to scroll alot to the right, just an fyi
ReplyDelete