dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.79k stars 3.19k forks source link

UnexpectedTrailingResultSetWhenSaving with Temporal Tables and Concurrency Token when updating multiple entity types in context #33243

Open andriivoloshchuk opened 8 months ago

andriivoloshchuk commented 8 months ago

Issue description

Hello, I stumbled upon this weird issue when was implementing optimistic concurrency scenario. When I update multiple enity types in one context save changes operation and each of those updates should cause DbUpdateConcurrencyException, the mentioned exception is not triggering for all of entity types in ThrowingConcurrencyExceptionAsync interceptor method. This issue happens only when the tables for these enteties are marked as Temporal.

Repro scenario

I have four identical simple entity types:

 public class FirstEntry
 {
     [Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
     public int Key { get; set; }
     public int Token { get; set;}
 }
 public class SecondEntry
 {
       [Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
       public int Key { get; set; }
       public int Token { get; set; }
 }
 // ... same ThirdEntry/FourthEntry

And model configuration:

   modelBuilder.Entity<FirstEntry>(builder =>
   {
       // the issue doesn't occur when table is not temporal
       builder.ToTable(nameof(FirstEntry), b => b.IsTemporal());
       builder.HasKey(e => e.Key);
       builder.Property(e => e.Token).IsConcurrencyToken(true);
   });
   // same for SecondEntry/ThirdEntry/FourthEntry

Then simple creating these entitites:

  using (AppDbContext appContext = new AppDbContext())
  {
      await appContext.Database.EnsureDeletedAsync();
      await appContext.Database.EnsureCreatedAsync();
      FirstEntry firstEntry = new FirstEntry() { Key = 1, Token = 1 };
      appContext.Add(firstEntry);
      // same for SecondEntry/ThirdEntry/FourthEntry
      await appContext.SaveChangesAsync();
  }

Then I simulate concurrency exception triggering by updating concurrency token original value (worth mentitoning that I get the same behaviour when causing concurrency exception from multiple threads scenario):

using (AppDbContext appContext = new AppDbContext())
{
    FirstEntry? dbFirst = await appContext.Set<FirstEntry>().Where(f => f.Key == 1).FirstOrDefaultAsync();
    if (dbFirst != null)
    {
        appContext.Entry(dbFirst).Property(e => e.Token).OriginalValue = 0; 
    }
    // same for SecondEntry/ThirdEntry/FourthEntry
    await appContext.SaveChangesAsync();
}

Then I have ThrowingConcurrencyExceptionAsync method from SaveChangesInterceptor where I have a breakpoint and simply suppress InterceptionResult to let it go through and observe the issue:

        public override ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(ConcurrencyExceptionEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default)
        {
            Console.WriteLine("ThrowingConcurrencyExceptionAsync for type {0}", eventData.Entries.FirstOrDefault()?.Entity.GetType().ToString());
            return new(InterceptionResult.Suppress());
        }

In this case the ThrowingConcurrencyExceptionAsync triggers 2/4 times for FirstEntity and SecondEntity. When the tables are not marked as temporal it is triggering 4/4 times for all the entety types.

Log

In the log I have this warning printed RelationalEventId.UnexpectedTrailingResultSetWhenSaving

dbug: 05/03/2024 09:19:04.621 CoreEventId.OptimisticConcurrencyException[10006] (Microsoft.EntityFrameworkCore.Update)
      Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
ThrowingConcurrencyExceptionAsync for type efconcurrency.FirstEntry
dbug: 05/03/2024 09:19:15.468 CoreEventId.OptimisticConcurrencyException[10006] (Microsoft.EntityFrameworkCore.Update)
      Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
ThrowingConcurrencyExceptionAsync for type efconcurrency.SecondEntry
warn: 05/03/2024 09:19:17.052 RelationalEventId.UnexpectedTrailingResultSetWhenSaving[20705] (Microsoft.EntityFrameworkCore.Update)
      An unexpected trailing result set was found when reading the results of a SaveChanges operation; this may indicate that a stored procedure returned a result set without being configured for it in the EF model. Check your stored procedure definitions.
dbug: 05/03/2024 09:19:17.055 RelationalEventId.DataReaderClosing[20301] (Microsoft.EntityFrameworkCore.Database.Command)
      Closing data reader to 'efconcurrency' on server 'localhost\SQLEXPRESS'.

Verbose Output

... Finding DbContext classes... Finding IDesignTimeDbContextFactory implementations... Finding application service provider in assembly 'efconcurrency'... Finding Microsoft.Extensions.Hosting service provider... No static method 'CreateHostBuilder(string[])' was found on class 'Program'. No application service provider was found. Finding DbContext classes in the project... Found DbContext 'AppDbContext'.



### Provider and version information

EF Core version: 8.0.2
Database provider: Microsoft.EntityFrameworkCore.SqlServer
Target framework: .NET 8.0
Operating system: Windows 10
IDE: Visual Studio 2022 17.8.2
ajcvickers commented 8 months ago

Note for team triage: Not a regression; still repros on latest daily.

Code ```C# using (var context = new SomeDbContext()) { context.Database.EnsureDeleted(); context.Database.EnsureCreated(); FirstEntry firstEntry = new FirstEntry() { Key = 1, Token = 1 }; context.Add(firstEntry); SecondEntry secondEntry = new SecondEntry() { Key = 1, Token = 1 }; context.Add(secondEntry); ThirdEntry thirdEntry = new ThirdEntry() { Key = 1, Token = 1 }; context.Add(thirdEntry); ForthEntry forthEntry = new ForthEntry() { Key = 1, Token = 1 }; context.Add(forthEntry); await context.SaveChangesAsync(); } using (var context = new SomeDbContext()) { FirstEntry? dbFirst = await context.Set().Where(f => f.Key == 1).FirstOrDefaultAsync(); if (dbFirst != null) { context.Entry(dbFirst).Property(e => e.Token).OriginalValue = 0; } SecondEntry? dbSecond = await context.Set().Where(f => f.Key == 1).FirstOrDefaultAsync(); if (dbSecond != null) { context.Entry(dbSecond).Property(e => e.Token).OriginalValue = 0; } ThirdEntry? dbThird = await context.Set().Where(f => f.Key == 1).FirstOrDefaultAsync(); if (dbThird != null) { context.Entry(dbThird).Property(e => e.Token).OriginalValue = 0; } ForthEntry? dbForth = await context.Set().Where(f => f.Key == 1).FirstOrDefaultAsync(); if (dbForth != null) { context.Entry(dbForth).Property(e => e.Token).OriginalValue = 0; } await context.SaveChangesAsync(); } public class MyInterceptor : SaveChangesInterceptor { public override ValueTask ThrowingConcurrencyExceptionAsync(ConcurrencyExceptionEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Console.WriteLine("ThrowingConcurrencyExceptionAsync for type {0}", eventData.Entries.FirstOrDefault()?.Entity.GetType().ToString()); return new(InterceptionResult.Suppress()); } } public class SomeDbContext : DbContext { private static MyInterceptor _interceptor = new (); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder .UseSqlServer(@"Data Source=localhost;Database=One;Integrated Security=True;Trust Server Certificate=True;ConnectRetryCount=0") .LogTo(Console.WriteLine, LogLevel.Information) .AddInterceptors(_interceptor) .EnableSensitiveDataLogging(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(builder => { // the issue doesn't occur when table is not temporal builder.ToTable(nameof(FirstEntry), b => b.IsTemporal()); builder.HasKey(e => e.Key); builder.Property(e => e.Token).IsConcurrencyToken(true); }); modelBuilder.Entity(builder => { // the issue doesn't occur when table is not temporal builder.ToTable(nameof(SecondEntry), b => b.IsTemporal()); builder.HasKey(e => e.Key); builder.Property(e => e.Token).IsConcurrencyToken(true); }); modelBuilder.Entity(builder => { // the issue doesn't occur when table is not temporal builder.ToTable(nameof(ThirdEntry), b => b.IsTemporal()); builder.HasKey(e => e.Key); builder.Property(e => e.Token).IsConcurrencyToken(true); }); modelBuilder.Entity(builder => { // the issue doesn't occur when table is not temporal builder.ToTable(nameof(ForthEntry), b => b.IsTemporal()); builder.HasKey(e => e.Key); builder.Property(e => e.Token).IsConcurrencyToken(true); }); } } public class FirstEntry { [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public int Key { get; set; } public int Token { get; set;} } public class SecondEntry { [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public int Key { get; set; } public int Token { get; set; } } public class ThirdEntry { [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public int Key { get; set; } public int Token { get; set; } } public class ForthEntry { [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public int Key { get; set; } public int Token { get; set; } } ```