Skip to content

Complex type init only Discriminator mistakenly marked as modified when unchanged (regression) #38105

@Kuurama

Description

@Kuurama

Bug description

Regression bug starting with <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" /> (it was fine on "10.0.2" and prior).

I'm not quite sure which specific part of the model configuration trigger the bug to happen, but it became impossible to save my entities to the database now as it falsely see a discriminator as being modified while it is "init-only".

It's also weird because it says BeatLeaderScore.Discriminator, but it's not an existing flied in the migration:

Image

Since the issue occurred from 10.0.3, it might be easier to track commits between 10.0.2 and 10.0.3 that might have impacted the regression.

Here is a strip down version of my model configuration:

public abstract class AbstractScore
{
    public int Id { get; set; }
    public required DateTimeOffset SetAt { get; init; }

    public required int? MaxCombo { get; init; }
    public required bool IsFullCombo { get; init; }

    public EScoreType Type { get; private init; }
    public enum EScoreType : byte { ScoreSaber = 0, BeatLeader = 1 }
}

public class AbstractScoreConfiguration : IEntityTypeConfiguration<AbstractScore>
{
    public void Configure(EntityTypeBuilder<AbstractScore> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .ValueGeneratedOnAdd();

        builder.HasIndex(x => x.SetAt);

        builder.HasDiscriminator(x => x.Type)
            .HasValue<ScoreSaberScore>(AbstractScore.EScoreType.ScoreSaber)
            .HasValue<BeatLeaderScore>(AbstractScore.EScoreType.BeatLeader)
            .IsComplete();
    }
}
public sealed class BeatLeaderScore : AbstractScore
{
    public required int? BeatLeaderScoreId { get; init; }
    public required ScoreStatistics? Statistics { get; init; }
}

public class BeatLeaderScoreConfiguration : IEntityTypeConfiguration<BeatLeaderScore>
{
    public void Configure(EntityTypeBuilder<BeatLeaderScore> builder)
    {
        builder.HasBaseType<AbstractScore>();
        builder.ComplexProperty(x => x.Statistics).Configure(new ScoreStatisticConfiguration());
    }
}

public class ScoreStatistics
{
    public WinTracker WinTracker { get; set; } = null!;
    public HitTracker HitTracker { get; set; } = null!;
    public AccuracyTracker AccuracyTracker { get; set; } = null!;
    public ScoreGraphTracker ScoreGraphTracker { get; set; } = null!;
}

public class ScoreStatisticConfiguration : IComplexPropertyConfiguration<ScoreStatistics>
{
    public ComplexPropertyBuilder<ScoreStatistics> Configure(ComplexPropertyBuilder<ScoreStatistics> builder)
    {
        builder.HasDiscriminator();
        builder.ComplexProperty(x => x.WinTracker)
            .ComplexProperty(x => x.AverageHeadPosition);
        builder.ComplexProperty(x => x.HitTracker);
        builder.ComplexProperty(x => x.AccuracyTracker);
        builder.ComplexProperty(x => x.ScoreGraphTracker);

        return builder;
    }
}

public readonly record struct AverageHeadPosition(float X, float Y, float Z);

public record WinTracker(
    bool IsWin,
    float EndTime,
    int PauseCount,
    float TotalPauseDuration,
    int TotalScore,
    int MaxScore
)
{
    public required AverageHeadPosition? AverageHeadPosition { get; init; }
}

public record HitTracker(int Max115Streak, float LeftTiming, float RightTiming,);

public record AccuracyTracker(
    float AccRight,
    float AccLeft,
    IReadOnlyList<float> LeftAverageCutGraphGrid,
    IReadOnlyList<float> RightAverageCutGraphGrid,
    IReadOnlyList<float> AccuracyGrid
);

public record ScoreGraphTracker(List<float> Graph);

public interface IComplexPropertyConfiguration<T> where T : notnull
{
    public ComplexPropertyBuilder<T> Configure(ComplexPropertyBuilder<T> builder);
}

public static class EFCoreComplexPropertyConfigurationExtensions
{
    public static ComplexPropertyBuilder<T> Configure<T>(
        this ComplexPropertyBuilder<T> self, IComplexPropertyConfiguration<T> builder) where T : notnull
        => builder.Configure(self);
}
public sealed class ScoreSaberScore : AbstractScore
{
    public required int ScoreSaberScoreId { get; init; }
    public required string? DeviceHmd { get; init; }
    public required string? DeviceControllerLeft { get; init; }
    public required string? DeviceControllerRight { get; init; }
}

public class ScoreSaberScoreConfiguration : IEntityTypeConfiguration<ScoreSaberScore>
{
    public void Configure(EntityTypeBuilder<ScoreSaberScore> builder)
    {
        builder.HasBaseType<AbstractScore>();

        builder.Property(x => x.DeviceHmd).HasMaxLength(30);
        builder.Property(x => x.DeviceControllerLeft).HasMaxLength(20);
        builder.Property(x => x.DeviceControllerRight).HasMaxLength(20);
    }
}

Your code

The full code of my project can be seen under this repository:
https://github.com/GuildSaber/GuildSaber

Stack traces

fail: Microsoft.EntityFrameworkCore.Update[10000]
      An exception occurred in the database while saving changes for context type 'GuildSaber.Database.Contexts.Server.ServerDbContext'.
      System.InvalidOperationException: The property 'BeatLeaderScore.Discriminator' is defined as read-only after it has been saved, but its value has been modified or marked as modified.
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.PrepareToSave()
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.GetEntriesToSave(Boolean cascadeChanges)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.<>c__DisplayClass30_0`2.<<ExecuteAsync>b__0>d.MoveNext()
      --- End of stack trace from previous location ---
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.ExecuteImplementationAsync[TState,TResult](Func`4 operation, Func`4 verifySucceeded, TState state, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.ExecuteImplementationAsync[TState,TResult](Func`4 operation, Func`4 verifySucceeded, TState state, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
      System.InvalidOperationException: The property 'BeatLeaderScore.Discriminator' is defined as read-only after it has been saved, but its value has been modified or marked as modified.
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntryBase.PrepareToSave()
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.GetEntriesToSave(Boolean cascadeChanges)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.<>c__DisplayClass30_0`2.<<ExecuteAsync>b__0>d.MoveNext()
      --- End of stack trace from previous location ---
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.ExecuteImplementationAsync[TState,TResult](Func`4 operation, Func`4 verifySucceeded, TState state, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.ExecuteImplementationAsync[TState,TResult](Func`4 operation, Func`4 verifySucceeded, TState state, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)

Verbose output


EF Core version

10.0.3

Database provider

No response

Target framework

.NET 10

Operating system

Linux

IDE

No response

Metadata

Metadata

Type

No fields configured for Bug.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions