Skip to content

fix(test): stop parallel tests racing the shared Bogus Noda cache - #579

Merged
alexeyzimarev merged 1 commit into
devfrom
fix/566-bogus-noda-parallel-flake
Aug 21, 2026
Merged

fix(test): stop parallel tests racing the shared Bogus Noda cache#579
alexeyzimarev merged 1 commit into
devfrom
fix/566-bogus-noda-parallel-flake

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Contributor

Fixes #566.

What was wrong

NaiveFixture held a single static readonly Faker<Commands.BookRoom>. f.Noda() resolves its dataset through Bogus.Premium.ContextHelper, which caches into ((IHasContext)faker).Context — a plain Dictionary<string, object> that belongs to that one Faker instance. TUnit runs tests in parallel, so several could hit the cache while it was still empty and corrupt it:

InvalidOperationException: Operations that change non-concurrent collections must have exclusive access.
  at System.Collections.Generic.Dictionary`2.set_Item(TKey key, TValue value)
  at Bogus.Premium.ContextHelper.GetOrSet[T](String key, Faker f, Func`1 factory)
  at Bogus.NodaTimeExtensions.Noda(Faker faker)
  at Eventuous.Tests.Fixtures.NaiveFixture..cctor b__6_0(Faker f)

The fix

Build the Faker per call (=>) instead of once (=), so each generation gets its own context dictionary and there is nothing to share.

This is already how DomainFixture is written in Eventuous.Tests.Persistence.Base, Eventuous.Tests.Redis, Eventuous.Tests.KurrentDB and Eventuous.Tests.Projections.MongoDB — all four use an expression-bodied property. NaiveFixture was the only fixture with a shared instance, which is why it was the only one flaking. The other suites needed no change.

Verification

Decompiled Bogus 35.6.5 to confirm the cache is per-Faker: ContextHelper.GetOrSet writes to ((IHasContext)f).Context, an instance auto-property initialised to new Dictionary<string, object>(), and Faker<T>.FakerHub is an instance field. Concurrent Faker construction is safe — Bogus.Database.Data is a Lazy<ConcurrentDictionary<..>> with ExecutionAndPublication.

A standalone harness comparing the two shapes over 200 cold rounds of 32 threads each:

shape result
static readonly Faker<T> Faker = new(...) failed 70/200 rounds
static Faker<T> Faker => new(...) 0/200

Eventuous.Tests on net10.0 then ran 12 consecutive times, 29 tests each, all green. The reported rate was roughly 1 in 10 runs, so the repeat runs are corroboration; the harness numbers and the removal of the shared instance are the actual evidence.

Not changed

src/Experimental/src/ElasticPlayground/Generator.cs has the same shared-static-Faker shape, but it is a manual playground whose scenarios run sequentially on one thread, so it is not at risk. Left alone to keep this diff to the reported bug.

🤖 Generated with Claude Code

NaiveFixture held one static Faker<BookRoom>, and f.Noda() caches its dataset in
that Faker's Bogus.Premium context — a plain Dictionary. TUnit runs tests in
parallel, so several could reach the cache on its first, still-empty use and
corrupt it, failing the suite with "Operations that change non-concurrent
collections must have exclusive access" from inside Bogus.

Builds the Faker per call instead, so each generation gets its own context. That
is already how DomainFixture is written in the Persistence, Redis, KurrentDB and
MongoDB suites, which is why none of them flake.

Measured with a standalone harness of 200 cold rounds, 32 threads each: the
shared-instance shape failed 70 rounds, the per-call shape none.

Fixes #566

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Fix parallel test flake from shared Bogus Noda cache in NaiveFixture

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Stop sharing a single Bogus Faker across parallel tests to prevent context-cache races.
• Generate a fresh Faker per command generation to isolate Bogus.Premium context state.
• Align NaiveFixture behavior with other test suite fixtures that already avoid shared Faker
 instances.
Diagram

graph TD
  A["TUnit parallel tests"] --> B["NaiveFixture"] --> C["Per-call Faker<BookRoom>"] --> D{{"Bogus.Noda()"}} --> E[("Faker Context Dictionary")]

  subgraph Legend
    direction LR
    _t["Test runner"] ~~~ _c["Code (class)"] ~~~ _e{{"External lib"}} ~~~ _d[("Instance state")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Lock around shared Faker usage
  • ➕ Keeps a single shared Faker instance (less allocation)
  • ➕ Minimal code churn if many call sites depend on the static field
  • ➖ Serializes test execution at the hotspot, reducing parallelism benefits
  • ➖ Easy to miss other unsafe shared state in Faker/custom rules
2. Use ThreadLocal/AsyncLocal Faker instances
  • ➕ Avoids per-call allocations while still isolating context per thread
  • ➕ Preserves parallelism better than a global lock
  • ➖ More complexity and lifecycle concerns (cleanup, thread reuse in runners)
  • ➖ Still risks accidental cross-thread sharing depending on execution model

Recommendation: Per-call Faker construction is the clearest and lowest-risk fix for a test-only flake: it guarantees Bogus’ per-Faker context dictionary is never shared across parallel tests, matches the established pattern in other suites, and avoids introducing locking or thread-local complexity.

Files changed (1) +4 / -1

Bug fix (1) +4 / -1
NaiveFixture.csCreate a new Faker per command generation to avoid parallel cache races +4/-1

Create a new Faker per command generation to avoid parallel cache races

• Replaces a shared static Faker<BookRoom> instance with an expression-bodied property that constructs a fresh Faker per use. Adds inline documentation explaining the Bogus.Noda() context-cache race under parallel test execution and why the per-call shape prevents it.

src/Core/test/Eventuous.Tests/Fixtures/NaiveFixture.cs

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@github-actions

Copy link
Copy Markdown

Test Results

   44 files  + 21     44 suites  +21   12m 31s ⏱️ - 6m 3s
  547 tests  -  17    547 ✅  -  15  0 💤 ±0  0 ❌  - 2 
1 098 runs  +523  1 098 ✅ +525  0 💤 ±0  0 ❌  - 2 

Results for commit 649dd95. ± Comparison against base commit 227c010.

This pull request removes 26 and adds 9 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/21/2026 11:03:21 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/21/2026 11:03:21)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2363e4c8-6b56-4f6e-9be5-db2c02a89c6e)
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncContextAwareHandler_ExistingBlob_ShouldUpdateStateAndContext
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncContextAwareHandler_NewBlob_ShouldUseContextAndStoreState
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncStateHandler_ExistingBlob_ShouldUpdateState
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ AsyncStateHandler_NewBlob_ShouldCreateAndStoreState
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ ConcurrentAdditionOfNewBlob_ShouldReturnFailure
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ ConcurrentModificationOfExistingBlob_ShouldReturnFailure
Eventuous.Tests.Azure.Storage.Blobs.BlobStorageProjectorTests ‑ CustomBlobId_ExistingBlob_ShouldUpdateWithEventId
…
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/21/2026 11:23:34 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/21/2026 11:23:34)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(a1c124ac-3c89-491a-be6e-e35cadcd90d2)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:25.3773080+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-21T11:19:25.3773080+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:25.3773080+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:26.1326685+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-21T11:19:26.1326685+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:26.1326685+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:27.3048391+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-21T11:19:27.3048391+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-21T11:19:27.3048391+00:00 })

@alexeyzimarev
alexeyzimarev merged commit 989e12d into dev Aug 21, 2026
16 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/566-bogus-noda-parallel-flake branch August 21, 2026 11:56
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.

Flaky Eventuous.Tests: NodaTime.Bogus context cache is not thread-safe under parallel tests

1 participant