koenbeuk / EntityFrameworkCore.Triggered

Triggers for EFCore. Respond to changes in your DbContext before and after they are committed to the database.
MIT License
554 stars 29 forks source link

Cannot access a disposed object. Object name: 'IServiceProvider'. #204

Open bzbetty opened 1 month ago

bzbetty commented 1 month ago

Occasionally hit the following which kills our app.

using latest version 3.

We've only recently implemented EFC Triggered, so we may have done something wrong - this issue is just in case you know about something, otherwise if we figure out what we did wrong we'll add to document it.

Exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'IServiceProvider'.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ThrowHelper.ThrowObjectDisposedException()
   at EntityFrameworkCore.Triggered.Internal.HybridServiceProvider.GetService(Type serviceType)
   at EntityFrameworkCore.Triggered.Internal.TriggerFactory.Resolve(IServiceProvider serviceProvider, Type triggerType)+MoveNext()
   at System.Linq.Enumerable.SelectIterator[TSource,TResult](IEnumerable`1 source, Func`3 selector)+MoveNext()
   at System.Collections.Generic.EnumerableHelpers.ToArray[T](IEnumerable`1 source, Int32& length)
   at System.Linq.Buffer`1..ctor(IEnumerable`1 source)
   at System.Linq.OrderedEnumerable`1.GetEnumerator()+MoveNext()
   at System.Linq.Enumerable.SelectIPartitionIterator`2.MoveNext()
   at System.Linq.Enumerable.CastIterator[TResult](IEnumerable source)+MoveNext()
   at EntityFrameworkCore.Triggered.TriggerSession.RaiseBeforeSaveStartingTriggers(CancellationToken cancellationToken)
   at EntityFrameworkCore.Triggered.Internal.TriggerSessionSaveChangesInterceptor.SavingChangesAsync(DbContextEventData eventData, InterceptionResult`1 result, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)

potentially related to #124?

we did add the dbcontextfactory by copying code from https://github.com/koenbeuk/EntityFrameworkCore.Triggered/pull/195

  public static IServiceCollection AddTriggeredDbContextFactory<TContext>(IServiceCollection serviceCollection, Action<IServiceProvider, DbContextOptionsBuilder>? optionsAction = null, ServiceLifetime lifetime = ServiceLifetime.Singleton)
      where TContext : DbContext
  {
      serviceCollection.AddDbContextFactory<TContext>((serviceProvider, options) => {
          optionsAction?.Invoke(serviceProvider, options);
          //options.UseTriggers();
      }, lifetime);

      var serviceDescriptor = serviceCollection.FirstOrDefault(x => x.ServiceType == typeof(IDbContextFactory<TContext>));

      if (serviceDescriptor?.ImplementationType != null)
      {
          var triggeredFactoryType = typeof(TriggeredDbContextFactory<,>).MakeGenericType(typeof(TContext), serviceDescriptor.ImplementationType);

          serviceCollection.TryAdd(ServiceDescriptor.Describe(
              serviceType: serviceDescriptor.ImplementationType,
              implementationType: serviceDescriptor.ImplementationType,
              lifetime: serviceDescriptor.Lifetime
          ));

          serviceCollection.Replace(ServiceDescriptor.Describe(
              serviceType: typeof(IDbContextFactory<TContext>),
              implementationFactory: serviceProvider => ActivatorUtilities.CreateInstance(serviceProvider, triggeredFactoryType, serviceProvider.GetRequiredService(serviceDescriptor.ImplementationType), serviceProvider),
              lifetime: ServiceLifetime.Transient
          ));
      }

      return serviceCollection;
  }

being called in program.cs (Azure Functions, dotnet 8, isolated)

            AddTriggeredDbContextFactory<ESPContext>(builder, (serviceProvider, opt) =>
            {                
                var secondLevelCacheInterceptor = serviceProvider.GetService<SecondLevelCacheInterceptor>();
                var triggerInterceptor = serviceProvider.GetService<TriggerSessionSaveChangesInterceptor>();

                opt.UseSqlServer(x =>
                {
                    x.UseHierarchyId();
                    x.UseQueryableValues(x =>
                    {
                        x.Serialization(SqlServerSerialization.UseJson);
                    });
                })
                .UseTriggers(triggerOptions => {
                    triggerOptions.AddAssemblyTriggers(ServiceLifetime.Scoped, typeof(Program).Assembly); 
                })
                .UseProjectables(c => c.CompatibilityMode(EntityFrameworkCore.Projectables.Infrastructure.CompatibilityMode.Full))
                .AddInterceptors(triggerInterceptor, secondLevelCacheInterceptor)
                .EnableSensitiveDataLogging();
            });

Also saw the following stackoverflow post which suggests it may be better to inject IServiceScopeFactory instead of IServiceProvider, which might make sense if there's a singleton object somewhere, but i haven't seen one.

https://stackoverflow.com/questions/76745332/c-sharp-timer-cannot-access-a-disposed-object-object-name-iserviceprovider

bzbetty commented 1 month ago

Adding the factory as transient instead of singleton may have fixed the issue.

Happened again, could be because I changed to a pool db factory (which has to be singleton) or perhaps I never fixed the issue

image

bzbetty commented 1 month ago

hmm I seem to also be able to get "SaveChangesWithoutTriggersAsync(), but it throws this exception 'System.InvalidOperationException: 'A triggerSession has already been created''!" occasionally too.

I think there's definitely some threadsafety or leakage issues happening for some reason.

bzbetty commented 1 month ago

Starting to think maybe Azure Functions (or my project) has something very different about it.

Added the EFCore Triggerred Source to my project and I'm hitting some rather weird Debug.Asserts

image

bzbetty commented 1 month ago

wonder if it's due to TriggerSessionSaveChangesInterceptor being registered against the db factory like I did, that might effectively make it shared?

bzbetty commented 1 month ago

yup, seems that was it!

using AddInterceptors like this is bad

 opt.UseSqlServer()
                .UseTriggers()
                .AddInterceptors(triggerInterceptor);

as it means it shares the one interceptor between all dbcontexts (threading issue).

adding it in the dbcontext in onconfiguring seems to work

  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
  {
      optionsBuilder.AddInterceptors(new TriggerSessionSaveChangesInterceptor());
      base.OnConfiguring(optionsBuilder);
  }

(still no idea why i had to add it myself, but triggers didn't fire without it)

bzbetty commented 1 month ago

That said you can't use OnConfiguring if Db Pooling is enabled :(

System.InvalidOperationException: 'OnConfiguring' cannot be used to modify DbContextOptions when DbContext pooling is enabled.

bzbetty commented 1 month ago

Curiously I think for the Db Pooling, it might actually work better to use the v2 mode where you extend the dbcontext instead.

May be a good reason not to remove it in v4?

bzbetty commented 1 month ago

also recommend against using

services.AddDbContext(options => options.UseTriggers(triggerOptions => triggerOptions.AddAssemblyTriggers()));

syntax as it does an assembly scan each context cretaion (50ms for me).