Skip to content

Make the ConcurrentDictionary GetOrCreate overload atomic - #131

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/extensions-130-concurrent-getorcreate
Sep 26, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/extensions-130-concurrent-getorcreate

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #130

Problem

DictionaryExtensions.GetOrCreate(ConcurrentDictionary<TKey,TVal>, key, defaultValue) did a non-atomic check-then-act: TryGetValue, then TryAdd. When two callers both missed, the loser's TryAdd returned false:

  • Debug builds: Debug.Assert(result) fired, which aborts the process.
  • Release builds: it returned its own defaultValue, which was never stored, so anything written to it was lost.

Fix

The body is now dictionary.GetOrAdd(key, defaultValue), so every caller gets the value actually stored. The Debug.Assert and the now-unused System.Diagnostics import are removed. The argument validation is unchanged. I did not add the optional factory overload the issue mentions, to keep this PR to the bug.

Tests

  • GetOrCreateConcurrentDictionaryShouldReturnStoredValueWhenAnotherCallerAddsFirst is deterministic. A comparer inserts a rival value on the second hash of the key, which is the moment between the old code's missed lookup and its TryAdd. The test asserts the returned instance is the stored one.
  • GetOrCreateConcurrentDictionaryShouldReturnSameInstanceToParallelCallers is the stress test from the issue. 1000 Parallel.For callers add to a ConcurrentBag, and the stored bag ends up with 1000 items.

With the fix reverted:

  • Release: the deterministic test fails on Assert.AreSame.
  • Debug: Debug.Assert aborts the test host (exit 134).

With the fix, the full suite passes in Debug and Release (136/136).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UHW69XTeuuLbfVdQdJ8uQh


Generated by Claude Code

The ConcurrentDictionary overload checked with TryGetValue and then called
TryAdd. When two callers missed at the same time, the loser's TryAdd
returned false, which fired Debug.Assert in Debug builds. In Release it
returned the loser's own defaultValue, which was never stored, so writes
to it were silently lost. It now uses GetOrAdd, which returns the stored
value to every caller.

Fixes #130

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHW69XTeuuLbfVdQdJ8uQh
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit fb9d8c4 into main Sep 26, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/extensions-130-concurrent-getorcreate branch September 26, 2026 09:52
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.

ConcurrentDictionary.GetOrCreate can return a value that was never stored when two threads race on the same key

2 participants