graphql-platform icon indicating copy to clipboard operation
graphql-platform copied to clipboard

What to do about DbContexts that depend on scoped services?

Open atrauzzi opened this issue 3 years ago • 12 comments

As part of getting up and running with Hot Chocolate, I followed the advice and updated my project to use a PooledDbContextFactory. This setup seems to be working fine during reads, but writes are posing an issue due to some functionality I had already built as part of my DbContext.

The refactor required that I switch to using service lookups inside of my DbContext as opposed to dependency injection as they now have a singleton lifetime. Entity Framework makes this possible by offering this extension method from the Microsoft.EntityFrameworkCore.Infrastructure namespace:

    // ...
    var service = this.GetService<MyService>();
    // ...

Unfortunately, if the service I request happens to be scoped, I get the following exception, which confirms my issue:

Cannot resolve scoped service 'MyProject.Global.Services.MyService' from root provider.

Taking apart the stacktrace does illustrate what's happening and where:

at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteValidator.ValidateResolution(Type serviceType, IServiceScope scope, IServiceScope rootScope)
at Microsoft.Extensions.DependencyInjection.ServiceProvider.Microsoft.Extensions.DependencyInjection.ServiceLookup.IServiceProviderEngineCallback.OnResolve(Type serviceType, IServiceScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
at Microsoft.EntityFrameworkCore.Infrastructure.Internal.InfrastructureExtensions.GetService[TService](IInfrastructure`1 accessor)

---

at Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService[TService](IInfrastructure`1 accessor)
(This is the important part.)

---

(Omitted: Unrelated logic I perform in the DB context.)

---

at MyProject.Conventions.Domain.MyProjectDbContext.SaveChangesAsync(CancellationToken cancellationToken)
(This is where control comes back to my DbContext when my mutation calls SaveChangesAsync)

---

at MyProject.Global.Api.GraphQl.GlobalAdminMutations.CreateSomething(MyProjectGlobalDbContext db, MyProjectGlobalContext context, MyService myService, CreateSomething request) in /home/atrauzzi/Development/my-project/global/MyProject.Global.Api/src/GraphQl/GlobalAdminMutations.cs:line 29
at HotChocolate.Resolvers.Expressions.ExpressionHelper.AwaitTaskHelper[T](Task`1 task)
at HotChocolate.Types.FieldMiddlewareCompiler.<>c__DisplayClass3_0.<<CreateResolverMiddleware>b__0>d.MoveNext()\n--- End of stack trace from previous location ---
at HotChocolate.Types.EntityFrameworkObjectFieldDescriptorExtensions.<>c__DisplayClass2_1`1.<<UseDbContext>b__4>d.MoveNext()\n--- End of stack trace from previous location ---
at HotChocolate.Types.EntityFrameworkObjectFieldDescriptorExtensions.<>c__DisplayClass2_1`1.<<UseDbContext>b__4>d.MoveNext()\n--- End of stack trace from previous location ---
at HotChocolate.Execution.Processing.ResolverTask.ExecuteResolverPipelineAsync(CancellationToken cancellationToken)
at HotChocolate.Execution.Processing.ResolverTask.TryExecuteAsync(CancellationToken cancellationToken)

I understand what's going on here, but what's not so clear at the moment is what I can do to resolve this.

I'm wondering - given that Hot Chocolate is encouraging people to use the pooled DB context - is there any way around this? Is there perhaps any kind of design advice that can be offered for people who are trying to adapt?

Is there something that Hot Chocolate can do at the same time as resolving DbContext instances like also creating a scope? Conceptually it makes sense to me that each field that is being executed asynchronously would also result in a scope being generated.

But then again, it might not matter because I'm using service location from within the now-static DbContext. So I feel like there also needs to be a way to obtain the IServiceProvider for the current field scope...

:thinking: - Definitely puzzled by this issue.

atrauzzi avatar Mar 02 '21 16:03 atrauzzi

We had a similar issue and opted to just use AddDbContextFactory(..., ServiceLifetime.Scoped) instead of AddPooledDbContextFactory. We didn't notice too much of an impact on the performance but YMMV.

I believe AddPooledDbContextFactory will never work if your DbContext has a dependency to a scoped service.

See https://github.com/dotnet/efcore/issues/14169#issuecomment-447398199

@daniel-white This is a known limitation of context pooling. Context pooling works by reusing the same context instance across requests. This means that it is effectively registered as a singleton in terms of the instance itself so that it is able to persist.

Context pooling is intended for scenarios where the context configuration, which includes services resolved, is fixed between requests. For cases where this needs to be changed, just don't use pooling. (Note that the performance gain from pooling is usually negligible accept in super-optimized scenarios. If you find that not to be the case, then we would love to see the code and the numbers.)

gojanpaolo avatar Mar 02 '21 17:03 gojanpaolo

@gojanpaolo - Thank you for that! I didn't realize you could go between the two, or that the lifetime parameter existed!

Did you happen to also add something like this to your project?

services.AddScoped((serviceProvider) => serviceProvider
    .GetRequiredService<IDbContextFactory<MyDbContext>>()
    .CreateDbContext());

I found I had to do this because AddDbContextFactory<MyDbContext>(...) doesn't result in a binding for MyDbContext.

atrauzzi avatar Mar 02 '21 17:03 atrauzzi

If you need to inject DbContext, then yes you need to explicitly register it as well because AddDbContextFactory will only add IDbContextFactory<> and DbContextOptions.

AddDbContextFactory source code
public static IServiceCollection AddDbContextFactory<TContext, TFactory>(
    [NotNull] this IServiceCollection serviceCollection,
    [CanBeNull] Action<IServiceProvider, DbContextOptionsBuilder>? optionsAction,
    ServiceLifetime lifetime = ServiceLifetime.Singleton)
    where TContext : DbContext
    where TFactory : IDbContextFactory<TContext>
{
    Check.NotNull(serviceCollection, nameof(serviceCollection));

    AddCoreServices<TContext>(serviceCollection, optionsAction, lifetime);

    serviceCollection.AddSingleton<IDbContextFactorySource<TContext>, DbContextFactorySource<TContext>>();

    serviceCollection.TryAdd(
        new ServiceDescriptor(
            typeof(IDbContextFactory<TContext>),
            typeof(TFactory),
            lifetime));

    return serviceCollection;
}
AddCoreServices source code
private static void AddCoreServices<TContextImplementation>(
    IServiceCollection serviceCollection,
    Action<IServiceProvider, DbContextOptionsBuilder>? optionsAction,
    ServiceLifetime optionsLifetime)
    where TContextImplementation : DbContext
{
    serviceCollection.TryAdd(
        new ServiceDescriptor(
            typeof(DbContextOptions<TContextImplementation>),
            p => CreateDbContextOptions<TContextImplementation>(p, optionsAction),
            optionsLifetime));

    serviceCollection.Add(
        new ServiceDescriptor(
            typeof(DbContextOptions),
            p => p.GetRequiredService<DbContextOptions<TContextImplementation>>(),
            optionsLifetime));
}

gojanpaolo avatar Mar 02 '21 17:03 gojanpaolo

Late to the party but just thought it worth mentioning that if you absolutely need pooling and access to a scoped service you can write your own scoped "accessor" utilizing AsyncLocal and wrapping your required service. Your accessor then gets registered as a singleton service. I had to do this to share a scoped IdentityContext class for authorisation across the DbContexts.

AFIK this is how IHttpContextAccessor works under the hood and I followed a similar design when I noticed that it worked flawlessly across the contexts, regardless of the parallel async contexts.

public class IdentityContextAccessor : IIdentityContextAccessor
    {
        private static AsyncLocal<IdentityContextHolder> identityContextCurrent = new AsyncLocal<IdentityContextHolder>();

        public IdentityContextAccessor()
        {
        }

        public IdentityContext IdentityContext
        {
            get
            {
                return identityContextCurrent.Value?.Context ?? new IdentityContext();
            }

            set
            {
                var holder = identityContextCurrent.Value;
                if (holder != null)
                {
                    // Clear current context trapped in the AsyncLocals, as its done.
                    holder.Context = null;
                }

                if (value != null)
                {
                    // Use an object indirection to hold the identity context in the AsyncLocal,
                    // so it can be cleared in all ExecutionContexts when its cleared.
                    identityContextCurrent.Value = new IdentityContextHolder { Context = value };
                }
            }
        }

        private class IdentityContextHolder
        {
            public IdentityContext? Context { get; set; }
        }
    }

braidenstiller avatar Apr 01 '21 05:04 braidenstiller

@braidenstiller I am having trouble understanding how does it solve resolving of scoped services from within pooled db context. I see you are manually instantiating IdentityContext what if the service I want to access has complicated dependency chain do I need to resolve them manually or I can still inject somehow into my Accessors?

kolpav avatar Sep 07 '21 08:09 kolpav

@braidenstiller I am having trouble understanding how does it solve resolving of scoped services from within pooled db context. I see you are manually instantiating IdentityContext what if the service I want to access has complicated dependency chain do I need to resolve them manually or I can still inject somehow into my Accessors?

Without fully seeing your requirements and the complexity of your dependency chain, one thing you could do is pass in your service container into the accessor and use it to create a service scope which can then be used to spin up the dependency you require, then store both the dependency and scope in asynclocals and then you can choose to pass around the scope as well or just the service. At the end disposing the scope.

Of course with this method there is some overhead since you're forced to resolve your entire dependency tree from the service collection but atleast its minimised since using Async local means it's only going to be done once across Async contexts (and most importantly not an arbitrary number of times) and thus not cause any issues with the pooled db context. I think when you're resolving complex dependency graphs this is probably one of the only way.

braidenstiller avatar Sep 08 '21 22:09 braidenstiller

@braidenstiller Makes sense, I have another question how do you inject the accessor service into pooled db context when you are required to have single public constructor accepting just db context options and nothing else? Otherwise you get:

Unhandled exception. System.InvalidOperationException: The DbContext of type 'ApplicationDbContext' cannot be pooled because it does not have a public constructor accepting a single parameter of type DbContextOptions or has more than one constructor.

By that requirement it seems you are not allowed to inject anything so I am curious how you did it?

kolpav avatar Sep 12 '21 14:09 kolpav

@braidenstiller Makes sense, I have another question how do you inject the accessor service into pooled db context when you are required to have single public constructor accepting just db context options and nothing else? Otherwise you get:

Unhandled exception. System.InvalidOperationException: The DbContext of type 'ApplicationDbContext' cannot be pooled because it does not have a public constructor accepting a single parameter of type DbContextOptions or has more than one constructor.

By that requirement it seems you are not allowed to inject anything so I am curious how you did it?

Good question! DbContext has a GetService extension method that can resolve services in the current scope from the service container. Here is my DbContext ctor copied verbatim:

        private readonly IIdentityContextAccessor identityContextAccessor;

        public CosmosDbContext(DbContextOptions<CosmosDbContext> options)
            : base(options)
        {
            this.identityContextAccessor = this.GetService<IIdentityContextAccessor>();
        }

The extension method: image

I found this after noticing DbContext uses this method internally to resolve its own dependencies, especially when used in pooled configuration.

braidenstiller avatar Sep 12 '21 22:09 braidenstiller

EDIT: If someone stumble upon this thread HC's DI improved a lot since then but if you really want to be able to depend on scoped services from singletons you can take inspiration from abp.io https://github.com/abpframework/abp/blob/e3e1779de6df5d26f01cdc8e99ac9cbcb3d24d3c/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/EntityHelper.cs#L276 make sure you understand how AsyncLocal works then there is internal db ctx api GetService that should get you started.

kolpav avatar Sep 24 '21 22:09 kolpav

@michaelstaib couldnt resolver scoped servcices help here?

PascalSenn avatar Jul 05 '22 19:07 PascalSenn

@PascalSenn no, since a DBContext is created on the root service provider context. Its a pooled object that cannot live in a scope. This is not a Hot Chocolate issue but how EFCore deals with DBContext pooling. In my opinion there are no surprises here. A pooled context cannot have access to services that have a different lifetime. The same goes for any singleton service.

michaelstaib avatar Sep 28 '22 05:09 michaelstaib