Skip to content

IsConstrained(false) relationship causes false circular dependency during SaveChanges #38978

Description

@mistermoser

Bug description

Summary

In EF Core 11, a relationship configured with IsConstrained(false) still participates as a non-breakable dependency in the SaveChanges modification-command graph.

This can cause SaveChanges() to throw a circular dependency exception even though there is no corresponding database foreign key constraint and the database can persist the graph safely.

A common example is:

  • Entity A has a SQL Server IDENTITY int primary key.
  • Entity B has a client-generated Guid primary key.
  • A contains BId, which references B.Id through an unconstrained EF relationship (IsConstrained(false)).
  • B contains RootId, which is a normal constrained FK referencing A.Id.

The database-valid insert order is:

  1. Insert A, including the already-known BId GUID. Since this relationship is unconstrained, B does not need to exist yet.
  2. Obtain A's generated IDENTITY value.
  3. Insert B using that value as RootId.

This should be possible in a single SaveChanges() call. EF may of course use a separate internal batch to retrieve and propagate the store-generated identity value.

Currently EF detects:

A -> B   // because A.BId references B.Id, even though IsConstrained(false)
B -> A   // real FK B.RootId -> A.Id

and reports a circular dependency before executing the valid insert sequence.

Minimal reproduction

Using:

Microsoft.EntityFrameworkCore.SqlServer 11.0.0-rc.1.26425.128

Example:

using Microsoft.EntityFrameworkCore;

public sealed class ReproContext : DbContext
{
    public DbSet<EntityA> EntitiesA => Set<EntityA>();
    public DbSet<EntityB> EntitiesB => Set<EntityB>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options
            .UseSqlServer(
                @"Server=(localdb)\mssqllocaldb;" +
                @"Database=EfCoreUnconstrainedCircularDependency;" +
                @"Trusted_Connection=True;" +
                @"TrustServerCertificate=True")
            .EnableSensitiveDataLogging();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<EntityA>(builder =>
        {
            builder.HasKey(x => x.Id);

            // SQL Server IDENTITY
            builder.Property(x => x.Id)
                .ValueGeneratedOnAdd();

            // A -> B is intentionally NOT backed by a database FK constraint.
            //
            // B.Id is client-generated and is therefore already known when A
            // is inserted.
            builder.HasOne(x => x.EntityB)
                .WithMany()
                .HasForeignKey(x => x.EntityBId)
                .IsConstrained(false);
        });

        modelBuilder.Entity<EntityB>(builder =>
        {
            builder.HasKey(x => x.Id);

            // Client-generated Guid.
            builder.Property(x => x.Id)
                .ValueGeneratedNever();

            // This IS a real database FK.
            // A must therefore be inserted first so that its IDENTITY value
            // can be propagated into EntityB.RootId.
            builder.HasOne(x => x.Root)
                .WithMany()
                .HasForeignKey(x => x.RootId)
                .OnDelete(DeleteBehavior.NoAction);
        });
    }
}

public sealed class EntityA
{
    public int Id { get; set; }

    public Guid EntityBId { get; set; }

    public EntityB EntityB { get; set; } = null!;
}

public sealed class EntityB
{
    public Guid Id { get; set; }

    public int RootId { get; set; }

    public EntityA Root { get; set; } = null!;
}

Reproduction:

await using var db = new ReproContext();

await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();

var entityBId = Guid.NewGuid();

var a = new EntityA
{
    EntityBId = entityBId
};

var b = new EntityB
{
    Id = entityBId,
    Root = a
};

a.EntityB = b;

db.AddRange(a, b);

await db.SaveChangesAsync();

SaveChangesAsync() currently fails because EF detects a circular dependency.

Expected behavior

SaveChanges() should succeed.

The required ordering is unambiguous:

INSERT EntityA
    EntityBId = <known Guid>

retrieve EntityA.Id

INSERT EntityB
    Id     = <known Guid>
    RootId = EntityA.Id

There is no database constraint requiring EntityB to exist before EntityA is inserted because the EntityA.EntityBId -> EntityB.Id relationship is configured with:

.IsConstrained(false)

The normal constrained relationship:

EntityB.RootId -> EntityA.Id

must still participate in command ordering and, because EntityA.Id is store-generated, must still create the appropriate batching boundary.

Actual behavior

The unconstrained relationship participates in the modification-command dependency graph in the same way as a constrained relationship.

This creates:

EntityB -> EntityA
EntityA -> EntityB

and CommandBatchPreparer.TopologicalSort() reports a circular dependency.

Why I believe this is specific to IsConstrained(false)

IsConstrained(false) explicitly means that the EF relationship is not backed by a database foreign key constraint.

The EF Core 11 implementation already takes this into account for:

  • relational model generation / migrations;
  • query join semantics;
  • the assumption that a matching principal exists.

However, command ordering still appears to treat the model-level foreign key as a mandatory store-ordering dependency.

CommandBatchPreparer currently handles model foreign keys without a mapped relational constraint in GetForeignKeyValues() / AddForeignKeyEdges() and eventually adds the edge using:

_modificationCommandGraph.AddEdge(
    predecessor,
    command,
    new CommandDependency(foreignKey),
    requiresBatchingBoundary);

The dependency therefore isn't breakable even when:

foreignKey.IsConstrained == false

and no store-generated value needs to be propagated across that particular relationship.

Possible implementation direction

Would it make sense for an unconstrained FK dependency to be breakable when it is not required for store-generated value propagation?

Conceptually something along the lines of:

var breakable =
    !foreignKey.IsConstrained
    && !requiresBatchingBoundary;

_modificationCommandGraph.AddEdge(
    predecessor,
    command,
    new CommandDependency(
        foreignKey,
        Breakable: breakable),
    requiresBatchingBoundary);

This would preserve the current useful ordering preference for unconstrained relationships, but would allow the topological sorter to break that edge when it is the only reason for a cycle.

It also preserves required ordering for cases where the principal key itself is store-generated.

For the reproduction above:

EntityB -> EntityA

from the unconstrained EntityA.EntityBId relationship could be broken.

The real:

EntityA -> EntityB

dependency from EntityB.RootId remains non-breakable and retains its batching boundary because EntityA.Id is store-generated.

The resulting operation is therefore:

EntityA
   ↓ retrieve IDENTITY
EntityB

which matches the actual database constraints.

EfCore11UnconstrainedCircularRepro.zip

Related issues

This seems closely related to the EF Core 11 implementation of unconstrained foreign-key relationships:

There are older issues involving circular relationships, such as #29183, but those predate IsConstrained(false) and involve relationships where both sides are treated as constrained.

This may look similar to #1699 / #29183, but this case is fundamentally different. Those issues contain a real circular database dependency and require cycle breaking by inserting a nullable FK as null and updating it afterwards.

In this case, one relationship is explicitly configured with IsConstraint(false), so there is no corresponding database FK and therefore no circular database dependency to break. A valid command ordering exists naturally: insert A, retrieve its generated identity, then insert B.

The question is whether a relationship configured with IsConstraint(false) should participate in CommandBatchPreparer dependency ordering at all.

I could not find an existing issue covering the specific case where an IsConstrained(false) relationship creates an otherwise artificial SaveChanges cycle.

Your code

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

const string defaultConnectionString =
    @"Server=(localdb)\mssqllocaldb;Database=EfCore11UnconstrainedCircularRepro;Trusted_Connection=True;TrustServerCertificate=True";

var connectionString = Environment.GetEnvironmentVariable("EF_REPRO_CONNECTION")
    ?? defaultConnectionString;

Console.WriteLine("EF Core 11 IsConstrained(false) circular SaveChanges repro");
Console.WriteLine("----------------------------------------------------------");
Console.WriteLine($"Connection: {connectionString}");
Console.WriteLine();
Console.WriteLine("WARNING: The repro deletes and recreates the configured database.");
Console.WriteLine();

await using var db = new ReproContext(connectionString);

await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();

var entityBId = Guid.NewGuid();

var a = new EntityA
{
    // A.Id is SQL Server IDENTITY and is therefore unknown until A is inserted.
    // B.Id, however, is already known now.
    EntityBId = entityBId
};

var b = new EntityB
{
    Id = entityBId,

    // Real constrained relationship. EF must insert A first, obtain its IDENTITY
    // value, propagate it into B.RootId and then insert B.
    Root = a
};

// Unconstrained relationship. This is intentionally *not* backed by a database FK.
// Since B.Id is already known, SQL Server can legally insert A before B.
a.EntityB = b;

db.AddRange(a, b);

Console.WriteLine("Tracked graph before SaveChanges:");
Console.WriteLine($"  A.Id        = {a.Id} (store-generated IDENTITY)");
Console.WriteLine($"  A.EntityBId = {a.EntityBId}");
Console.WriteLine($"  B.Id        = {b.Id} (client-generated Guid)");
Console.WriteLine($"  B.RootId    = {b.RootId} (must receive A.Id)");
Console.WriteLine();

Console.WriteLine("Expected valid database ordering:");
Console.WriteLine("  1. INSERT A with the already-known B Guid (no DB FK constraint A -> B)");
Console.WriteLine("  2. Read generated A.Id");
Console.WriteLine("  3. INSERT B with RootId = A.Id (real DB FK B -> A)");
Console.WriteLine();

try
{
    await db.SaveChangesAsync();

    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine("SaveChanges SUCCEEDED.");
    Console.ResetColor();
    Console.WriteLine();
    Console.WriteLine("By running an EF Core version containing a fix for the issue, this is expected.");
    Console.WriteLine($"Generated A.Id: {a.Id}");
    Environment.ExitCode = 0;
}
catch (Exception ex)
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine("SaveChanges FAILED.");
    Console.ResetColor();
    Console.WriteLine();
    Console.WriteLine(ex);
    Console.WriteLine();
    Console.WriteLine("This reproduces the issue if the exception reports a circular dependency between A and B.");
    Environment.ExitCode = 1;
}

public sealed class ReproContext(string connectionString) : DbContext
{
    public DbSet<EntityA> EntitiesA => Set<EntityA>();
    public DbSet<EntityB> EntitiesB => Set<EntityB>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(connectionString)
            .EnableSensitiveDataLogging()
            .EnableDetailedErrors()
            .LogTo(Console.WriteLine, LogLevel.Information);

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<EntityA>(builder =>
        {
            builder.ToTable("EntityA");
            builder.HasKey(x => x.Id);

            // SQL Server IDENTITY int PK.
            builder.Property(x => x.Id)
                .UseIdentityColumn();

            // B.Id is client-generated and is known before SaveChanges.
            // This EF navigation is intentionally NOT backed by a database FK constraint.
            builder.HasOne(x => x.EntityB)
                .WithMany()
                .HasForeignKey(x => x.EntityBId)
                .OnDelete(DeleteBehavior.NoAction)
                .IsConstrained(false);
        });

        modelBuilder.Entity<EntityB>(builder =>
        {
            builder.ToTable("EntityB");
            builder.HasKey(x => x.Id);

            builder.Property(x => x.Id)
                .ValueGeneratedNever();

            // This is a real database FK. A must exist before B can be inserted.
            builder.HasOne(x => x.Root)
                .WithMany()
                .HasForeignKey(x => x.RootId)
                .OnDelete(DeleteBehavior.NoAction);
        });
    }
}

public sealed class EntityA
{
    public int Id { get; set; }

    public Guid EntityBId { get; set; }

    public EntityB EntityB { get; set; } = null!;
}

public sealed class EntityB
{
    public Guid Id { get; set; }

    public int RootId { get; set; } // Referring back to A with real database FK constraint.

    public EntityA Root { get; set; } = null!;
}

Stack traces


Verbose output


EF Core version

11.0.0-rc.1.26425.128

Database provider

Microsoft.EntityFrameworkCore.SqlServer

Target framework

v11.0.100-rc.1

Operating system

Windows 11

IDE

Visual Studio 2026 18.10.0

Activity

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

Metadata

Metadata

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions