Make the EfMap mapping cache thread-safe - #54
Open
gentledepp wants to merge 1 commit into
Open
Conversation
EfMap.Get caches one DbMapping per model in a static Dictionary, reading with TryGetValue and writing with an unsynchronised indexer assignment. Two threads arriving on a cold cache can therefore read the dictionary while another is writing it, which throws or returns the wrong entry. The failure does not surface as a dictionary error. MappedDataReader resolves each entity type inside a try/catch (it has to, so unmapped derived types are skipped), so a mapping lookup that blows up is swallowed per type and the resulting empty collection is reported as "No table mappings provided." That sends you looking at your model rather than at a race. Reproduced by running integration tests that bulk insert from more than one thread: serially they pass, concurrently they fail with that message. Switch to a ConcurrentDictionary and GetOrAdd. Two threads may still each build a DbMapping on first use with one result discarded, which is wasteful but correct, and unlike a Lazy it does not cache a failed build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
EfMap.Get(DbContext)caches oneDbMappingper model in a staticDictionary, reading it withTryGetValueand writing it with an unsynchronised indexer assignment:Two threads arriving on a cold cache can read that dictionary while another is writing it, which throws or returns the wrong entry.
Why it is hard to recognise
The failure does not look like a dictionary problem, because it surfaces in one of two disguises:
Exception: No table mappings provided.—MappedDataReader<T>'s constructor resolves each derived type inside atry { } catch { }, which it has to do so that unmapped derived types are skipped. A mapping lookup that blows up is therefore swallowed per type, and the resulting empty collection is reported as if the model were wrong. That sends you looking at your entity configuration rather than at a race.NullReferenceExceptionout ofDictionary.Insert— the corrupted-internal-state signature. In our case this also took the whole test host down mid-run, with no failing test reported.Reproduction
An EF6 integration suite with fixtures running in parallel, several of them bulk inserting. Serially it passes; concurrently it fails intermittently with the message above. Adding a locked warm-up that builds the cache once before the parallel work starts made it go away, which is what pointed at the cache rather than the model.
It only needs concurrent first use, so it is rare in a long-running app and much more likely in a test suite or a job host that starts several workers at once.
The change
ConcurrentDictionaryandGetOrAdd.Two threads may still each construct a
DbMappingon first use, with one result discarded. That is wasteful but correct — and unlike aLazy<T>it does not cache a failed construction, so a transient failure while building the mapping does not poison the entry for the lifetime of the process.DbMapping's constructor maps the whole context eagerly and the instance is only read afterwards, so once an entry is published there is nothing further to synchronise.Scope
This is not test-only: any application that bulk inserts from more than one thread — concurrent background jobs, or requests — can hit the same window on first use.