Skip to content

Fix persistent subscription failure handler never being invoked#546

Merged
alexeyzimarev merged 6 commits intodevfrom
fix/persistent-subscription-nack
Apr 29, 2026
Merged

Fix persistent subscription failure handler never being invoked#546
alexeyzimarev merged 6 commits intodevfrom
fix/persistent-subscription-nack

Conversation

@alexeyzimarev
Copy link
Copy Markdown
Contributor

@alexeyzimarev alexeyzimarev commented Apr 29, 2026

Summary

Four things in this branch:

1. Persistent subscription failure handler unreachable (closes #544)

  • Removes the early throw in PersistentSubscriptionBase.Nack that made _handleEventProcessingFailure unreachable, so the configured (or default) failure handler now actually runs when the handler throws with ThrowOnError = true.
  • Default behaviour is now what the docs and original design described: server-side Nack(Retry, ...) → retries up to MaxRetryCount → message parked. Subscription stays connected.
  • Adds an integration test that reproduces the bug: produces one event, fails the handler with maxRetryCount: 0, and asserts the message lands on $persistentsubscription-{stream}::{group}-parked (read with resolveLinkTos: true).
  • Minor fixture tweak: PersistentSubscriptionFixture exposes SubscriptionId and Client so the test can address the parked stream.

The reachability bug, in case it's useful for review: Nack is only entered from the catch in HandleEvent, that catch only fires when Handler re-throws, and Handler only re-throws when ThrowOnError = true. So if (Options.ThrowOnError) throw exception; was effectively unconditional, and the failure handler below it was dead code.

2. OpenTelemetry 1.13.x → 1.15.x

Clears GHSA-g94r-2vxg-569j (excessive memory allocation parsing baggage/B3/Jaeger headers — fixed in 1.15.3). All 1.13.x references bumped, with the two beta-only packages on the matching 1.15.x betas.

3. Preview pipeline: MyGet → nuget.org

MyGet has been unreachable, so the dev-branch publish job now mirrors the release pipeline and pushes to nuget.org (Release config + symbols). MinVer keeps preview builds versioned as pre-releases, so they're safe to land on nuget.org.

4. Release pipeline: trusted publishing, drop MyGet

  • Removes the second dotnet nuget push to MyGet from publish.yml.
  • Switches the release push to NuGet OIDC trusted publishing via NuGet/login@v1. Adds id-token: write at the job level so the runner can mint the OIDC token for the profile already configured on nuget.org.
  • NuGet user defaults to Eventuous but can be overridden via the NUGET_USER repository variable.

Once this lands and the next release runs green, the NUGET_API_KEY, MYGET_API_KEY, and MYGET_FEED_NAME secrets/variables can be revoked from the repo settings. (The preview workflow still uses NUGET_API_KEY; if you want to flip preview to trusted publishing too, just say the word and I'll mirror the change there.)

Test plan

  • New Esdb_ShouldParkMessageWhenHandlerFails test passes (was red before the fix)
  • Existing HandlerFailureResubscribe (catch-up subscription drop & resubscribe path) still passes
  • Existing persistent subscription happy-path tests (SubscribeAndProduceMany) still pass
  • Full solution builds cleanly after the OpenTelemetry bump
  • Trusted publishing end-to-end: verify on next tag push that NuGet/login succeeds and dotnet nuget push accepts the short-lived key

When the handler throws and `ThrowOnError = true`, `PersistentSubscriptionBase.Nack`
re-threw the exception before reaching `_handleEventProcessingFailure`. Because
`Nack` is only ever entered through that re-throw path, the failure handler was
unreachable: the subscription dropped on every failure instead of issuing a
server-side Nack and letting KurrentDB retry/park the message.

Removing the early throw lets the default failure handler send `Nack(Retry, ...)`
so the server retries up to `MaxRetryCount` and parks afterwards, while the
subscription stays connected.

Adds an integration test that produces an event, fails the handler with
`maxRetryCount: 0`, and asserts the message lands on
`$persistentsubscription-{stream}::{group}-parked` (read with `resolveLinkTos: true`).

Closes #544
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Fix persistent subscription nack not invoking failure handler

🐞 Bug fix 🧪 Tests

Grey Divider

Walkthroughs

Description
• Removes early throw in Nack method that prevented failure handler execution
• Allows server-side retry and message parking when handler fails with ThrowOnError = true
• Adds integration test verifying failed messages are parked correctly
• Exposes SubscriptionId and Client in fixture for test access
Diagram
flowchart LR
  A["Handler throws<br/>ThrowOnError=true"] --> B["Nack called"]
  B --> C["Remove early throw"]
  C --> D["Execute failure handler"]
  D --> E["Send Nack Retry<br/>to server"]
  E --> F["Server retries<br/>up to MaxRetryCount"]
  F --> G["Message parked<br/>Subscription stays connected"]
Loading

Grey Divider

File Changes

1. src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs 🐞 Bug fix +0/-4

Remove early throw blocking failure handler execution

• Removes the early throw exception statement that made _handleEventProcessingFailure
 unreachable
• Removes redundant MessageHandlingFailed logging that duplicates core subscription logic
• Allows the default failure handler to execute and send server-side Nack(Retry, ...) for proper
 retry/park behavior

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs


2. src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/PersistentSubscriptionFixture.cs 🧪 Tests +13/-10

Expose subscription ID and client for test access

• Exposes SubscriptionId as public property for test access to parked stream names
• Exposes Client property to allow tests to read parked message streams
• Adds KurrentDB.Client using statement for KurrentDBClient type
• Minor formatting alignment of property declarations

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/PersistentSubscriptionFixture.cs


3. src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs 🧪 Tests +107/-0

Add integration test for message parking on handler failure

• New test Esdb_ShouldParkMessageWhenHandlerFails reproduces the bug scenario with `maxRetryCount:
 0`
• Verifies failed event is parked to $persistentsubscription-{stream}::{group}-parked stream
• Implements AlwaysFailingHandler that throws on every event to trigger failure path
• Includes helper method ReadFirstParkedEvent with polling and timeout logic to verify parked
 message

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented Apr 29, 2026

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. await missing .NoContext() 📘 Rule violation ☼ Reliability
Description
The newly added integration test awaits several tasks without using the repository’s .NoContext()
convention, which can cause unintended sync-context capture and deadlock risk in library-style async
flows. This violates the checklist requirement to consistently apply .NoContext() on awaits.
Code

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[R21-31]

+        await fixture.InitializeAsync();
+
+        try {
+            await fixture.Start();
+
+            var testEvent = TestEvent.Create();
+            await fixture.Producer.Produce(fixture.Stream, testEvent, new(), cancellationToken: cancellationToken);
+
+            var parkedStream = $"$persistentsubscription-{fixture.Stream}::{fixture.SubscriptionId}-parked";
+            var parked       = await ReadFirstParkedEvent(fixture.Client, parkedStream, TimeSpan.FromSeconds(20), cancellationToken);
+
Evidence
PR Compliance ID 5 requires using async I/O with .NoContext() on awaits where applicable. The
added test awaits fixture.InitializeAsync(), fixture.Start(), Produce(...), and
ReadFirstParkedEvent(...) without .NoContext(), and ReadFirstParkedEvent awaits
read.ReadState/Task.Delay without .NoContext().

CLAUDE.md
src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[21-31]
src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[75-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new test code awaits tasks without using the repository-standard `.NoContext()` (ConfigureAwait(false)) convention.
## Issue Context
`TaskExtensions.NoContext()` exists in this repo and is used to avoid sync-context capture.
## Fix Focus Areas
- src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[21-38]
- src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[65-90]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Subscription not stopped🐞 Bug ☼ Reliability
Description
PersistentSubscriptionFailureTests starts the subscription with autoStart=false but never calls
Stop, and PersistentSubscriptionFixture.DisposeAsync() won’t unsubscribe in this mode, leaving a
running background subscription after the test completes. This can leak resources and interfere with
other tests in the same process.
Code

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[R23-38]

+        try {
+            await fixture.Start();
+
+            var testEvent = TestEvent.Create();
+            await fixture.Producer.Produce(fixture.Stream, testEvent, new(), cancellationToken: cancellationToken);
+
+            var parkedStream = $"$persistentsubscription-{fixture.Stream}::{fixture.SubscriptionId}-parked";
+            var parked       = await ReadFirstParkedEvent(fixture.Client, parkedStream, TimeSpan.FromSeconds(20), cancellationToken);
+
+            await Assert.That(parked).IsNotNull();
+            await Assert.That(parked!.Value.Event.EventStreamId).IsEqualTo(fixture.Stream.ToString());
+            await Assert.That(parked.Value.Event.EventType).IsEqualTo(TestEvent.TypeName);
+            await Assert.That(fixture.Handler.Failures).IsGreaterThan(0);
+        } finally {
+            await fixture.DisposeAsync();
+        }
Evidence
The test manually starts the subscription and only disposes the fixture; the fixture’s DisposeAsync
stops the subscription only when autoStart is true, so this test leaves the subscription running.

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[14-38]
src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/PersistentSubscriptionFixture.cs[28-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Esdb_ShouldParkMessageWhenHandlerFails` manually calls `fixture.Start()` but never calls `fixture.Stop()`. Because the fixture only calls `Stop()` from `DisposeAsync()` when `autoStart` is `true`, this test leaves a persistent subscription running after completion.
### Issue Context
This can cause resource leaks and cross-test interference/flakiness, especially when multiple persistent-subscription tests run in the same process.
### Fix Focus Areas
- src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/PersistentSubscriptionFailureTests.cs[23-38]
- src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/PersistentSubscriptionFixture.cs[48-51]
### Suggested change
In the test’s `finally` block, call `await fixture.Stop();` before `await fixture.DisposeAsync();` (or make `DisposeAsync()` stop if the subscription is running, regardless of `autoStart`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Ack failure not logged 🐞 Bug ◔ Observability
Description
PersistentSubscriptionBase.Nack no longer logs MessageHandlingFailed; exceptions thrown after
Handler returns (e.g., subscription.Ack failures) will now be sent to the failure handler without
any error log. This reduces diagnosability for failures that occur outside the consumer pipeline.
Code

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[R188-190]

     var re           = ctx.Items.GetItem<ResolvedEvent>(ResolvedEventKey);
     var subscription = ctx.Items.GetItem<PersistentSubscription>(SubscriptionKey)!;
     await _handleEventProcessingFailure(Client, subscription, re, exception).NoContext();
Evidence
Handler/pipeline failures are already logged via context.Nack inside EventSubscription.Handler, but
exceptions thrown later in PersistentSubscriptionBase.HandleEvent (like Ack failures) are only
caught by HandleEvent and routed to Nack. After this PR, Nack no longer emits any log entry before
invoking the failure handler, so those Ack-stage failures can become silent.

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[137-152]
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[183-191]
src/Core/src/Eventuous.Subscriptions/EventSubscription.cs[110-146]
src/Core/src/Eventuous.Subscriptions/Context/ContextResultExtensions.cs[12-41]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PersistentSubscriptionBase.Nack` no longer logs failures. While handler exceptions are already logged by `context.Nack(...)` inside `EventSubscription.Handler`, failures occurring after `Handler(context)` returns (notably `Ack(context)` failures) are now routed to the failure handler with no explicit log entry.
### Issue Context
In `PersistentSubscriptionBase.HandleEvent`, `Ack(context)` is inside the same try block as `Handler(context)`, so Ack exceptions are caught and routed to `Nack(context, e)`. Those exceptions are not logged by `EventSubscription.Handler` (since it already returned).
### Fix Focus Areas
- src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[137-152]
- src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[183-191]
### Suggested change
Add logging in `Nack` but avoid double-logging handler failures, e.g.:
- If `!ctx.HasFailed()` (or `ctx.HandlingResults.GetFailureStatus() == 0`), call `ctx.LogContext.MessageHandlingFailed(Options.SubscriptionId, ctx, exception)` before invoking `_handleEventProcessingFailure`.
This preserves single logging for handler failures, while restoring logs for Ack-stage failures.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Park reason loses cause🐞 Bug ◔ Observability
Description
With the failure handler now reachable, handler failures will typically arrive as a
SubscriptionException wrapper (from ThrowOnError), and the default failure handler uses
exception.Message as the Nack reason. This means parked messages may record a generic "Error
processing event..." reason instead of the original handler exception message.
Code

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[R188-190]

     var re           = ctx.Items.GetItem<ResolvedEvent>(ResolvedEventKey);
     var subscription = ctx.Items.GetItem<PersistentSubscription>(SubscriptionKey)!;
     await _handleEventProcessingFailure(Client, subscription, re, exception).NoContext();
Evidence
When ThrowOnError is true, EventSubscription.Handler rethrows as SubscriptionException with the
original exception as InnerException. PersistentSubscriptionBase catches that exception and passes
it to the failure handler; DefaultEventProcessingFailureHandler then sends exception.Message to the
server, losing the underlying cause in the parked reason.

src/Core/src/Eventuous.Subscriptions/EventSubscription.cs[136-145]
src/Core/src/Eventuous.Subscriptions/Exceptions.cs[16-18]
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[137-152]
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[242-249]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Now that the failure handler is invoked, the default Nack reason sent to KurrentDB comes from `exception.Message`. In the common ThrowOnError path, that exception is a `SubscriptionException` wrapper, so the parked message reason becomes generic and hides the original handler error.
### Issue Context
`EventSubscription.Handler` throws `SubscriptionException(..., innerException)` when `ThrowOnError` is enabled. `DefaultEventProcessingFailureHandler` currently uses `exception.Message`.
### Fix Focus Areas
- src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/PersistentSubscriptionBase.cs[242-249]
- src/Core/src/Eventuous.Subscriptions/Exceptions.cs[16-18]
### Suggested change
Update the default failure handler (or unwrap before invoking `_handleEventProcessingFailure`) to prefer the underlying cause, e.g.:
- If `exception is SubscriptionException se && se.InnerException != null`, use `se.InnerException.Message` (or `se.InnerException.ToString()` if acceptable) as the Nack reason.
- Otherwise, fall back to `exception.Message`.
This keeps parked reasons actionable without changing the retry/park behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

- Updates OpenTelemetry packages from 1.13.x to 1.15.x to clear
  GHSA-g94r-2vxg-569j (excessive memory allocation in baggage/B3/Jaeger
  propagation header parsing). 1.15.3 is the first patched version per
  the advisory.
- Bumps OpenTelemetry.Instrumentation.AspNetCore to 1.15.2 (latest
  stable) and OpenTelemetry.Instrumentation.GrpcNetClient to 1.15.1-beta.1
  (latest beta).
- Switches the preview workflow to publish to nuget.org with
  NUGET_API_KEY, mirroring the release pipeline. MyGet is currently
  unreachable, and dev-branch builds are MinVer-tagged as previews so
  they're safe to push to nuget.org.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33560109d3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +36 to +37
} finally {
await fixture.DisposeAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop started subscription in teardown

This test explicitly starts a persistent subscription with autoStart: false, but the finally block only calls DisposeAsync(). In this fixture, DisposeAsync() only invokes Stop() when autoStart is true, so the subscription remains active after the test. In longer test runs this can leak active consumers/connections and introduce cross-test flakiness; stop the subscription explicitly before disposing.

Useful? React with 👍 / 👎.

- Drops the MyGet push from publish.yml; MyGet is unreachable and the
  release pipeline now publishes only to nuget.org.
- Replaces the long-lived NUGET_API_KEY secret with NuGet OIDC trusted
  publishing via the NuGet/login@v1 action. Adds id-token: write at the
  job level so the runner can mint the OIDC token for the trust profile
  configured on nuget.org.
- NuGet user defaults to 'Eventuous' but is overridable via the
  NUGET_USER repository variable.

Once this is green the NUGET_API_KEY and MYGET_* secrets/variables can
be revoked from the repository settings.
- Quote the api-key substitution to be defensive against future key formats.
- Drop the leftover NUGET_AUTH_TOKEN env. It's a NuGet config-provider
  variable, not consumed by `dotnet nuget push` against nuget.org, and
  next to a trusted-publishing step it's actively misleading.
Three real issues from reviewers, plus one transitive-vuln cleanup.

Skipped: the suggestion to add `.NoContext()` everywhere in the new
test — that convention is for library async paths, not test code.

* Log Ack-stage failures in PersistentSubscriptionBase.Nack
  Handler-pipeline failures are still logged exactly once (via
  `context.Nack` inside `EventSubscription.Handler`); anything that
  reaches `Nack` without `HasFailed()` (e.g. an `Ack` throwing after the
  handler returned) now gets its own `MessageHandlingFailed` entry, so
  it no longer fails silently before being routed to the failure handler.

* Unwrap SubscriptionException in DefaultEventProcessingFailureHandler
  When `ThrowOnError = true`, `Handler` wraps the original exception in
  `SubscriptionException`. The default failure handler used
  `exception.Message` for the Nack reason, which made parked messages
  carry the generic "Error processing event ..." string. Now it prefers
  `InnerException.Message` when available so the parked reason carries
  the actual handler error.

* Stop the persistent subscription before disposing in the new test
  The fixture only auto-stops when `autoStart: true`, so the test was
  leaking a running subscription. Track whether `Start()` succeeded and
  call `Stop()` in the `finally` before `DisposeAsync()`. Also tighten
  the parked-event helper to throw `TimeoutException` on timeout instead
  of returning null, removing the awkward `parked!.Value` chain.

* Pin OpenTelemetry.Api in Eventuous.KurrentDB
  KurrentDB.Client transitively pulls OpenTelemetry.Api 1.12.0, which is
  also in scope for GHSA-g94r-2vxg-569j. Adding a direct PackageReference
  forces the central pinned 1.15.3 across Eventuous.KurrentDB and every
  project that consumes it, clearing the remaining NU1902 warnings
  without enabling repo-wide transitive pinning (which cascades into
  unrelated framework-version downgrades).
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 29, 2026

Test Results

   66 files  + 44     66 suites  +44   40m 4s ⏱️ + 25m 13s
  362 tests + 11    362 ✅ + 11  0 💤 ±0  0 ❌ ±0 
1 089 runs  +727  1 089 ✅ +727  0 💤 ±0  0 ❌ ±0 

Results for commit 8f20989. ± Comparison against base commit 2896e22.

This pull request removes 5 and adds 16 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/7/2026 9:36:20 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/7/2026 9:36:20 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(76595545-35eb-4f28-a733-d32ec29b004f)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-04-07T21:36:20.1355297+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-04-07T21:36:20.1355297+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-04-07T21:36:20.1355297+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2c94f6ee-30e1-4c65-8be1-1a482ed57fce)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/29/2026 4:18:12 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/29/2026 4:18:12 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/29/2026 4:18:24 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/29/2026 4:18:24 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/29/2026 4:18:42 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4/29/2026 4:18:42 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(72265c04-9496-423f-a8d3-cb1d7b7216a8)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(f671692b-9f80-4f8a-91e6-2f72c59cd160)
Eventuous.Tests.KurrentDB.Subscriptions.PersistentSubscriptionFailureTests ‑ Esdb_ShouldParkMessageWhenHandlerFails
…

♻️ This comment has been updated with latest results.

`SpyglassRegistry.Aggregates` was a plain `List<T>` mutated from
`[ModuleInitializer]` methods. When multiple sample assemblies are
referenced from the same test process and TUnit loads/initialises them
in parallel (which happens whenever more than one test forces an
assembly via `RuntimeHelpers.RunModuleConstructor`), the concurrent
`List.Add` calls race and entries are lost. That's the root cause of
the flaky `Aggregates_contains_payment_state_as_standalone` test seen
on the .NET 8 leg of CI: the `PaymentState` registration sometimes never
made it into the list.

Switch the backing store to a copy-on-write `SpyglassAggregateInfo[]`
under a lock for writes, and read from the snapshot on every query.
Lookups remain allocation-free for the hit path, registrations are
serialised, and readers always see a consistent snapshot.
@alexeyzimarev alexeyzimarev merged commit 3376015 into dev Apr 29, 2026
6 checks passed
@alexeyzimarev alexeyzimarev deleted the fix/persistent-subscription-nack branch April 29, 2026 16:25
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.

Nack doesn't work with persistent subscriptions (KurrentDB)

1 participant