Skip to content

Make the EfMap mapping cache thread-safe - #54

Open
gentledepp wants to merge 1 commit into
ghost1face:masterfrom
gentledepp:fix/efmap-thread-safety
Open

Make the EfMap mapping cache thread-safe#54
gentledepp wants to merge 1 commit into
ghost1face:masterfrom
gentledepp:fix/efmap-thread-safety

Conversation

@gentledepp

Copy link
Copy Markdown

EfMap.Get(DbContext) caches one DbMapping per model in a static Dictionary, reading it with TryGetValue and writing it with an unsynchronised indexer assignment:

private static readonly Dictionary<string, DbMapping> Mappings = new Dictionary<string, DbMapping>();

public static DbMapping Get(DbContext context)
{
    ...
    if (Mappings.TryGetValue(key, out var value)) return value;
    value = new DbMapping(context);
    Mappings[key] = value;
    return value;
}

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 a try { } 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.
  • NullReferenceException out of Dictionary.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

ConcurrentDictionary and GetOrAdd.

Two threads may still each construct a DbMapping on first use, with one result discarded. That is wasteful but correct — and unlike a Lazy<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.

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>
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.

1 participant