Skip to content

GetRelationalModel throws "The model must be finalized" when two threads make the first call at once (check-then-act on RelationalModelFactory) #39214

Description

@mesutk

Bug description

RelationalModelExtensions.GetRelationalModel(IModel) can throw InvalidOperationException: The model must be finalized and its runtime dependencies must be initialized before 'GetRelationalModel' can be used when two threads make the first call for the same model at the same time.

Since EF 9 (#33650, "Make RelationalModel lazy"), the relational model is built on first use. The method reads two runtime annotations without synchronization between the reads:

// src/EFCore.Relational/Extensions/RelationalModelExtensions.cs (v10.0.12, unchanged on main)
public static IRelationalModel GetRelationalModel(this IModel model)
{
    var relationalModel = (IRelationalModel?)model.FindRuntimeAnnotationValue(RelationalAnnotationNames.RelationalModel);
    if (relationalModel == null)
    {
        var relationalModelFactory = (Func<IRelationalModel>?)model.FindRuntimeAnnotationValue(
                RelationalAnnotationNames.RelationalModelFactory)
            ?? throw new InvalidOperationException(CoreStrings.ModelNotFinalized(nameof(GetRelationalModel)));
        lock (relationalModelFactory)
        {
            relationalModel = model.GetOrAddRuntimeAnnotationValue(
                RelationalAnnotationNames.RelationalModel, f => f!(), relationalModelFactory);
            model.RemoveRuntimeAnnotation(RelationalAnnotationNames.RelationalModelFactory);
        }
    }

    return relationalModel;
}

The failing interleaving:

  1. Thread B reads RelationalModel and gets null.
  2. Thread A completes the whole first touch: it stores RelationalModel and removes RelationalModelFactory.
  3. Thread B reads RelationalModelFactory, gets null, and throws ModelNotFinalized, although the model is fully built.

The lock only protects threads that already hold the factory; the throw happens before it. After the first touch completes, every call takes the fast path, so this only happens once per model object.

Where we hit it

Integration tests run many test classes in parallel in one process, and each migrates its own database with context.Database.MigrateAsync(), using contexts of the same type (same provider configuration, different connection strings).

  • Those contexts share one internal service provider, so they also share one cached design-time model.
  • Migrator.ValidateMigrations calls HasPendingModelChanges(), which calls _designTimeModel.Model.GetRelationalModel(). That is the first touch of the shared design-time model.
  • Several classes reaching it at the same moment occasionally fail. In our runs it failed once in a few thousand cases, under CPU oversubscription.
System.InvalidOperationException : The model must be finalized and its runtime dependencies must be initialized before 'GetRelationalModel' can be used. Ensure that either 'OnModelCreating' has completed or, if using a stand-alone 'ModelBuilder', that 'IModelRuntimeInitializer.Initialize(model.FinalizeModel())' was called.
   at Microsoft.EntityFrameworkCore.RelationalModelExtensions.GetRelationalModel(IModel model)
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.HasPendingModelChanges()
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.ValidateMigrations(Boolean useTransaction, String targetMigration)
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.MigrateAsync(String targetMigration, CancellationToken cancellationToken)
   at Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Internal.NpgsqlMigrator.MigrateAsync(String targetMigration, CancellationToken cancellationToken)
   at (application code calling context.Database.MigrateAsync())

The runtime model gets the same lazy RelationalModelFactory (RelationalModelRuntimeInitializer), so as far as we can tell two concurrent first queries or SaveChanges calls on a context type right after application start can hit the same race. We have not observed that path.

Your code

A deterministic reproduction. A RuntimeModel subclass holds thread B between its two reads while thread A completes the first touch. It uses internal API (EF1001) only to force the interleaving; the race itself needs no internal API.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <NoWarn>EF1001</NoWarn>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
  </ItemGroup>
</Project>
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Internal;

var model = new PausingModel();
var relational = new RelationalModel(model);
model.AddRuntimeAnnotation(RelationalAnnotationNames.RelationalModelFactory, (Func<IRelationalModel>)(() => relational));
Exception? failure = null;
var b = new Thread(() =>
{
    try { model.GetRelationalModel(); }
    catch (Exception e) { failure = e; }
});
model.Held = b;
b.Start();
model.Reached.Wait();                 // B has read RelationalModel == null
var a = model.GetRelationalModel();   // A: the first touch completes
model.Release.Set();
b.Join();
Console.WriteLine($"A got the relational model: {ReferenceEquals(a, relational)}");
Console.WriteLine($"B: {failure?.GetType().Name}: {failure?.Message}");

internal sealed class PausingModel : RuntimeModel
{
    public Thread? Held;
    public readonly ManualResetEventSlim Reached = new();
    public readonly ManualResetEventSlim Release = new();

    public override Annotation? FindRuntimeAnnotation(string name)
    {
        var annotation = base.FindRuntimeAnnotation(name);
        if (name == RelationalAnnotationNames.RelationalModel && annotation is null && Thread.CurrentThread == Held)
        {
            Reached.Set();
            Release.Wait();
        }

        return annotation;
    }
}

Output:

A got the relational model: True
B: InvalidOperationException: The model must be finalized and its runtime dependencies must be initialized before 'GetRelationalModel' can be used. Ensure that either 'OnModelCreating' has completed or, if using a stand-alone 'ModelBuilder', that 'IModelRuntimeInitializer.Initialize(model.FinalizeModel())' was called.

Suggested fix

Re-read RelationalModel when the factory is gone, before throwing. For example:

var relationalModel = (IRelationalModel?)model.FindRuntimeAnnotationValue(RelationalAnnotationNames.RelationalModel);
if (relationalModel == null)
{
    var relationalModelFactory = (Func<IRelationalModel>?)model.FindRuntimeAnnotationValue(
        RelationalAnnotationNames.RelationalModelFactory);
    if (relationalModelFactory == null)
    {
        // another thread may have completed the first touch between the two reads
        return (IRelationalModel?)model.FindRuntimeAnnotationValue(RelationalAnnotationNames.RelationalModel)
            ?? throw new InvalidOperationException(CoreStrings.ModelNotFinalized(nameof(GetRelationalModel)));
    }

    lock (relationalModelFactory)
    {
        relationalModel = model.GetOrAddRuntimeAnnotationValue(
            RelationalAnnotationNames.RelationalModel, f => f!(), relationalModelFactory);
        model.RemoveRuntimeAnnotation(RelationalAnnotationNames.RelationalModelFactory);
    }
}

return relationalModel;

Our workaround is to make the first GetRelationalModel() call for each design-time and runtime model one thread at a time:

  • under a process-wide lock before migrating;
  • for every registered context once at host start, before the host serves requests.

Stack traces

See above.

Verbose output

No response

EF Core version

10.0.12 (the method is unchanged on main; it has been lazy since EF 9)

Database provider

Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 (the race is in EFCore.Relational, so it is provider-independent)

Target framework

.NET 10.0

Operating system

macOS 27 (arm64) and Ubuntu 24.04 (x64, CI)

IDE

No response

Activity

  1. mesutk commented on Oct 9, 2026

    @mesutk
    ContributorAuthor

    I'd like to contribute the fix for this, if the team is happy with the approach.

    The change is the one in "Suggested fix" above. When RelationalModelFactory is missing, GetRelationalModel reads RelationalModel once more before throwing ModelNotFinalized. The path after the first call stays a single annotation lookup, and the extra read only happens in the race. A model that is genuinely not finalized still throws; the existing GetRelationalModel_throws_if_convention_has_not_run covers that.

    It's ready on a branch: mesutk@edf6374

    • src/EFCore.Relational/Extensions/RelationalModelExtensions.cs (+8/-2).
    • A deterministic regression test in test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs: GetRelationalModel_does_not_throw_if_relational_model_is_created_concurrently. It is single-threaded, with no sleeps: a RuntimeModel subclass completes the first call from inside FindRuntimeAnnotation, exactly between the two reads. It fails without the change and passes with it.
    • Verified locally on main (2892c33):
      • Debug and Release builds: 0 warnings;
      • EFCore.ApiBaseline.Tests: 14/14 (no public API change);
      • EFCore.Relational.Tests: 1520 passed, 1 existing skip;
      • EFCore.Sqlite.FunctionalTests: 0 failures.

    Two notes:

    • The race isn't limited to migrations. Every mapping accessor (GetTableMappings, GetViewMappings, GetDefaultMappings, ...) reaches GetRelationalModel through EnsureRelationalModel, so the first concurrent query or SaveChanges on a fresh runtime model can hit it too.
    • This is a regression from EF 8, introduced by Make RelationalModel lazy #33650 (the relational model became lazy in EF 9). If you'd consider servicing it for 10.0, I'm happy to open a port to release/10.0 as well.

    May I open the PR against main?

  2. AndriySvyryd commented on Oct 9, 2026

    @AndriySvyryd
    Member

    @mesutk Go ahead.

  3. added this to the 12.0.0 milestone on Oct 9, 2026
  4. added theissue type on Oct 9, 2026
  5. self-assigned this
    on Oct 9, 2026
  6. added a commit that references this issue on Oct 9, 2026
    2e24e08
  7. added a commit that references this issue on Oct 14, 2026
    791bc2c
  8. thomhurst commented on Oct 24, 2026

    @thomhurst

    Would the team consider servicing #39216 into 10.0.x? We hit this race on EF Core 10.0.13 with Npgsql and SQLite. Two concurrent first SaveChangesAsync calls on a freshly built model can throw ModelNotFinalized from GetRelationalModel, both in parallel test runs and on the first concurrent requests after a service starts. release/10.0 still has the check-then-act code, so 10.0 users currently need a workaround (we replace IModelRuntimeInitializer to build the relational model inside the model-creation lock).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions