Skip to content

Fix ghost entry in EntityReferenceMap after identity conflict during Add#38587

Closed
AndriySvyryd with Copilot wants to merge 3 commits into
mainfrom
copilot/fix-changetracker-haschanges-issue
Closed

Fix ghost entry in EntityReferenceMap after identity conflict during Add#38587
AndriySvyryd with Copilot wants to merge 3 commits into
mainfrom
copilot/fix-changetracker-haschanges-issue

Conversation

Copilot AI commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

ChangeTracker.HasChanges() could return false and SaveChanges() could return an incorrect row count after catching an identity conflict exception and detaching the offending entity.

Root cause

When Add(entity) throws an identity conflict, FireStateChanging had already moved the entry from the detached bucket to the added bucket in EntityReferenceMap. The outer SetEntityState correctly restores EntityState = Detached, but SetEntityState(Detached, Detached) is a no-op — so the entry is left as a ghost in _addedReferenceMap with EntityState.Detached. This ghost inflated SaveChanges row counts and corrupted ChangedCount.

context.Foos.Add(foo2);         // throws: identity conflict
context.Entry(foo2).State = EntityState.Detached;  // no-op — EntityState already Detached,
                                                    // but entry still in _addedReferenceMap
context.Bars.Add(new Bar());
context.ChangeTracker.HasChanges();  // false — ChangedCount was -1
context.SaveChanges();               // returned 2 instead of 1

Changes

  • InternalEntityEntry.OnStateChanging — wrap StateManager.StartTracking(this) in try/catch; on failure, call StateManager.RevertStartTracking(this, newState) to undo the reference map move before rethrowing.
  • IStateManager — new void RevertStartTracking(InternalEntityEntry entry, EntityState requestedState).
  • StateManager — implements RevertStartTracking by delegating to the existing private UpdateReferenceMaps(entry, entry.EntityState, requestedState), which removes the entry from the wrongly-placed bucket without adding it to the detached map.
  • FakeStateManager — stub to satisfy the interface.
  • StateManagerTest — four regression tests covering HasChanges, ChangedCount, Entries enumeration, and SaveChanges row count after an identity conflict + detach.

Copilot AI and others added 2 commits July 9, 2026 02:00
Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
When StartTracking throws (e.g. identity conflict), FireStateChanging had
already moved the entry from the detached bucket to the added bucket in
EntityReferenceMap. Roll back that move in a catch block via the new
RevertStartTracking method so the entry is not left as a ghost in the wrong
reference map bucket, which was corrupting SaveChanges row counts and
causing HasChanges() to return false.

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix ChangeTracker.HasChanges() behavior when detaching entity Fix ghost entry in EntityReferenceMap after identity conflict during Add Jul 9, 2026
Copilot AI requested a review from AndriySvyryd July 9, 2026 02:05
@AndriySvyryd
AndriySvyryd deleted the copilot/fix-changetracker-haschanges-issue branch July 10, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StateManager.ChangeCount becomes -1 after detaching entity — breaks HasChanges()

2 participants