Hold detached entries weakly in the change tracker#38387
Open
ajcvickers wants to merge 3 commits into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds coverage and fixes around caching InternalEntityEntry instances for detached/untracked entities without preventing GC, addressing weak-reference behavior regressions (issue #33557).
Changes:
- Switch detached-entry caching from a strong
DictionarytoConditionalWeakTableinEntityReferenceMap. - Add multiple GC-focused tests verifying detached-entry caching semantics for normal and shared-type entities, plus graph detach/add scenarios.
- Extend the test model with shared-type entities and new test-only entity types for reproductions.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/EFCore.Tests/ChangeTracking/Internal/StateManagerTest.cs | Adds regression tests for weak detached-entry cache behavior and graph detach/add scenarios. |
| src/EFCore/ChangeTracking/Internal/EntityReferenceMap.cs | Updates detached entity cache to use ConditionalWeakTable to avoid keeping entities alive. |
Comments suppressed due to low confidence (1)
src/EFCore/ChangeTracking/Internal/EntityReferenceMap.cs:1
ConditionalWeakTable<TKey, TValue>in the BCL exposesAdd,Remove,TryGetValue, andGetValueAPIs, but notAddOrUpdate. Unless this repo defines anAddOrUpdateextension forConditionalWeakTable, this will fail to compile. Prefer usingRemove+Add(orTryGetValue+Remove/Add) to update an existing key, or useGetValue(key, ...)where appropriate.
// Licensed to the .NET Foundation under one or more agreements.
AndriySvyryd
reviewed
Jun 9, 2026
| .UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider); | ||
| } | ||
|
|
||
| private class Deal |
Member
There was a problem hiding this comment.
There's no need to create new test entity type classes for these tests, prefer to use existing ones when possible
TL;DR - Fixes dotnet#33557: a long-lived DbContext leaked entities that were detached via ctx.Entry(x).State = Detached. - Root cause: _detachedReferenceMap (the cache backing ctx.Entry on untracked entities) held entities with STRONG references for the context's lifetime. - Fix: make _detachedReferenceMap a ConditionalWeakTable so cached detached entries no longer keep their entities alive. - The originally-reported InvalidOperationException is already fixed on main; the remaining defect was purely the leak. - No API change, ~12 lines, no measurable perf impact. Background ---------- When ctx.Entry(entity) is called for an untracked entity, the StateManager creates an InternalEntityEntry in the Detached state and caches it in EntityReferenceMap._detachedReferenceMap so that subsequent calls for the same instance return the same entry (and a later Add/Attach/Update acts on it). That map was a Dictionary<object, InternalEntityEntry> keyed by the entity instance, so it held the entity with a strong reference until the entry transitioned to a tracked state or ChangeTracker.Clear()/dispose was called. Detaching an entity (ctx.Entry(x).State = Detached) is a no-op state change that never transitions the entry out of the map, so the entity stayed rooted by the context. In the reported scenario the principal's detach cascade-detaches the dependents, so the subsequent ctx.Entry(...) calls in the user's detach loop ran against already-detached entities, caching - and thus leaking - them. Rejected approaches ------------------- 1. Evict the cached entry on the no-op SetEntityState(Detached). Breaks the entry-stability contract pinned by 15 existing tests (DbSetTest / DbContextTest / SharedTypeDbSetTest.Can_use_*_to_change_entity_state): var entry = ctx.Entry(e); // caches entry dotnet#1 entry.State = Detached; // no-op ctx.Add(e); // must transition entry dotnet#1 -> Added Evicting dotnet#1 makes Add create a new entry, so the caller's `entry` no longer reflects the Add. 2. Any other purely state/flag-based heuristic for "release on detach". Same problem. The ONLY thing that distinguishes the leak case (the user no longer references the entity) from the stability case (the user still holds and reuses the entity) is whether the entity is still reachable. EF cannot know that from change-tracking state - only the GC does. That is precisely what a weak reference expresses, so weak references are required, not just one option among several. Fix --- Change _detachedReferenceMap to ConditionalWeakTable<object, InternalEntityEntry>. CWT holds the key (entity) weakly via a DependentHandle, so: - While the entity is referenced elsewhere, the cached entry is found and reused (entry-stability contract preserved). - Once nothing else references the entity, the entity and its cached entry become collectable even though the context is still alive (leak fixed). The change is contained to the field and the add site (AddOrUpdate); TryGetValue, Remove and the Clear (= null) paths are syntactically identical. Detached entries are never enumerated - GetCountForState / GetEntriesForState / GetNonDeletedEntities all exclude Detached - so no enumeration-based code is affected. CWT uses reference equality, matching the previous ReferenceEqualityComparer. Perf ---- Measured on the InMemory provider (Release, min of repeated runs), comparing this change against main: - Attach a 400k-entity graph (exercises detached-map add + transition-remove per node): ~416 ms (main) vs ~403-415 ms (this change). - ctx.Entry() on 500k untracked entities (pure detached-map add): ~335 ms vs ~324-335 ms. No measurable regression; the detached-map operations are a small fraction of the attach cost and CWT add/remove/lookup is comparable to Dictionary for this workload. Note: CWT allocates a GC-tracked DependentHandle per cached detached entry, so a workload creating millions of distinct untracked entities via ctx.Entry() without tracking them would create more handles than the old Dictionary. Tests ----- Added to StateManagerTest: - Entry_for_untracked_entity_is_cached_while_the_entity_is_referenced - the stability contract (same entry returned while the entity is referenced). - Entry_for_untracked_entity_does_not_keep_the_entity_alive - GC/WeakReference test proving a cached detached entry no longer roots its entity. - Detaching_a_tracked_graph_does_not_retain_references_to_detached_entities - GC/WeakReference test reproducing the issue's detach-loop pattern. - Can_add_an_equivalent_graph_after_detaching_a_graph_with_the_same_keys - pins the already-fixed no-exception / no-save-detached behavior.
…ared-type coverage Test-only changes (production untouched), from a review of 84365e1d86: - Add GC.KeepAlive(stateManager) to Entry_for_untracked_entity_does_not_keep_the_entity_alive. Without it the state manager that owns the detached-entity cache could itself be collected during the GC, releasing the entity even with the old strong Dictionary cache - a false negative that would not catch a regression. - Add Entry_for_untracked_entity_survives_collection_while_the_entity_is_referenced: the dual of the leak test, pinning entry-stability across a GC while the entity is still referenced (guards against a future over-eager eviction). - Add Entry_for_untracked_shared_type_entity_does_not_keep_the_entity_alive plus a SharedEntity shared-type model (SharedEntityA/B), covering the per-type sub-map (_sharedTypeReferenceMap) weak path, which previously had no leak coverage. Verified: all 3 leak tests go RED against the pre-fix strong Dictionary and GREEN with the ConditionalWeakTable fix; the stability tests pass against both. Full StateManagerTest = 48 passing. Test project uses workstation GC, so the IsAlive==false assertions are deterministic here.
Address PR feedback to re-use existing entity types instead of adding new ones. The graph tests now use the self-referencing Widget type (a multi-level parent/child graph that cascade-detaches) instead of new Deal/Transaction/Flow types, and the shared-type test reuses Category as the shared CLR type via a focused BuildModelWithSharedType helper. This also fixes the tests so they compile: the shared-type test referenced an undefined SharedEntity type and built a model without the shared-type entity it queried.
55f2fb9 to
b58b1e0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #33557: A long-lived
DbContextleaked entities that were detached viactx.Entry(x).State = Detached.Full details from Claude
Background
When ctx.Entry(entity) is called for an untracked entity, the StateManager creates an InternalEntityEntry in the Detached state and caches it in EntityReferenceMap._detachedReferenceMap so that subsequent calls for the same
instance return the same entry (and a later Add/Attach/Update acts on it).
That map was a Dictionary<object, InternalEntityEntry> keyed by the entity instance, so it held the entity with a strong reference until the entry transitioned to a tracked state or ChangeTracker.Clear()/dispose was called.
Detaching an entity (ctx.Entry(x).State = Detached) is a no-op state change that never transitions the entry out of the map, so the entity stayed rooted by the context. In the reported scenario the principal's detach cascade-detaches
the dependents, so the subsequent ctx.Entry(...) calls in the user's detach loop ran against already-detached entities, caching - and thus leaking - them.
Rejected approaches
Evict the cached entry on the no-op SetEntityState(Detached).
Breaks the entry-stability contract pinned by 15 existing tests
(DbSetTest / DbContextTest / SharedTypeDbSetTest.Can_use_*_to_change_entity_state):
var entry = ctx.Entry(e); // caches entry Added inital solution structure, early metadata support, and utilities. #1
entry.State = Detached; // no-op
ctx.Add(e); // must transition entry Added inital solution structure, early metadata support, and utilities. #1 -> Added
Evicting Added inital solution structure, early metadata support, and utilities. #1 makes Add create a new entry, so the caller's
entryno longer reflects the Add.Any other purely state/flag-based heuristic for "release on detach".
Same problem. The ONLY thing that distinguishes the leak case (the user no longer references the entity) from the stability case (the user still holds and reuses the entity) is whether the entity is still reachable. EF cannot
know that from change-tracking state - only the GC does. That is precisely what a weak reference expresses, so weak references are required, not just one option among several.
Fix
Change _detachedReferenceMap to ConditionalWeakTable<object, InternalEntityEntry>.
CWT holds the key (entity) weakly via a DependentHandle, so:
The change is contained to the field and the add site (AddOrUpdate); TryGetValue,Remove and the Clear (= null) paths are syntactically identical. Detached entries are never enumerated - GetCountForState / GetEntriesForState / GetNonDeletedEntities all exclude Detached - so no enumeration-based code is affected. CWT uses reference
equality, matching the previous ReferenceEqualityComparer.
Perf
Measured on the InMemory provider (Release, min of repeated runs), comparing this
change against main:
node): ~416 ms (main) vs ~403-415 ms (this change).
~324-335 ms.
No measurable regression; the detached-map operations are a small fraction of the attach cost and CWT add/remove/lookup is comparable to Dictionary for this workload. Note: CWT allocates a GC-tracked DependentHandle per cached detached entry, so a workload creating millions of distinct untracked entities via ctx.Entry() without
tracking them would create more handles than the old Dictionary.
Tests
Added to StateManagerTest: