Repository navigation
GetRelationalModel throws "The model must be finalized" when two threads make the first call at once (check-then-act on RelationalModelFactory) #39214
Description
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:
- Thread B reads
RelationalModeland getsnull. - Thread A completes the whole first touch: it stores
RelationalModeland removesRelationalModelFactory. - Thread B reads
RelationalModelFactory, getsnull, and throwsModelNotFinalized, 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.ValidateMigrationscallsHasPendingModelChanges(), 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
mesutk commentedon Oct 9, 2026
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
RelationalModelFactoryis missing,GetRelationalModelreadsRelationalModelonce more before throwingModelNotFinalized. 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 existingGetRelationalModel_throws_if_convention_has_not_runcovers that.It's ready on a branch: mesutk@edf6374
src/EFCore.Relational/Extensions/RelationalModelExtensions.cs(+8/-2).test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs:GetRelationalModel_does_not_throw_if_relational_model_is_created_concurrently. It is single-threaded, with no sleeps: aRuntimeModelsubclass completes the first call from insideFindRuntimeAnnotation, exactly between the two reads. It fails without the change and passes with it.Two notes:
GetTableMappings,GetViewMappings,GetDefaultMappings, ...) reachesGetRelationalModelthroughEnsureRelationalModel, so the first concurrent query orSaveChangeson a fresh runtime model can hit it too.release/10.0as well.May I open the PR against
main?AndriySvyryd commentedon Oct 9, 2026
@mesutk Go ahead.
Fix GetRelationalModel throwing when the relational model is created …
Fix GetRelationalModel race condition (dotnet#39216)
test: stop the net9 composite-key sample racing EF GetRelationalModel (…
thomhurst commentedon Oct 24, 2026
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
SaveChangesAsynccalls on a freshly built model can throwModelNotFinalizedfromGetRelationalModel, both in parallel test runs and on the first concurrent requests after a service starts.release/10.0still has the check-then-act code, so 10.0 users currently need a workaround (we replaceIModelRuntimeInitializerto build the relational model inside the model-creation lock).