Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,17 @@ public CheckpointCommitHandler(

return;

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Diagnostics only")]
async ValueTask Process(IReadOnlyList<CommitPosition> list, CancellationToken cancellationToken) {
_positions.UnionWith(list);

// Emit the commit diagnostic here, on the single worker thread that owns _positions. It used
// to be written from Commit() on the ACK caller thread, whose _positions.Min read raced this
// loop's UnionWith/RemoveWhere on the non-thread-safe SortedSet — a data race that threw
// NullReferenceException out of the ack path and wedged the subscription (AI-1329).
if (Diagnostic.IsEnabled(CommitOperation))
Diagnostic.Write(CommitOperation, new CommitEvent(_subscriptionId, _positions.Max, _positions.Min));

Comment on lines +72 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remediation recommended

2. Metrics can go stale 🐞 Bug ◔ Observability

SubscriptionMetrics derives gap/time and pending-checkpoints from CommitEvent diagnostics, but
diagnostics now emit only from the single commit-worker Process loop. If the worker is blocked
awaiting CommitInternal (e.g., slow checkpoint store), ACKs can keep calling Commit and enqueueing
positions while no new CommitEvent is emitted, causing metrics to lag/freeze and misrepresent
backlog and processing lag.
Agent Prompt
### Issue description
`SubscriptionMetrics` uses `CheckpointCommitMetrics`, which only updates on `CheckpointCommitHandler`'s `CommitOperation` diagnostic events. This PR moved `Diagnostic.Write("Commit", ...)` from `Commit()` (ACK caller thread) to the worker-thread `Process()` loop. Because the worker is single-threaded and awaits checkpoint-store I/O in `CommitInternal`, diagnostics (and thus metrics) can stop updating while ACKs continue enqueueing positions.

### Issue Context
This change fixes a real data race, but it also changes telemetry semantics: metrics now reflect *worker ingestion/progress*, not *ACK/processing progress*, and can appear frozen during slow checkpoint commits.

### Fix Focus Areas
- src/Core/src/Eventuous.Subscriptions/Checkpoints/CheckpointCommitHandler.cs[64-96]
- src/Core/src/Eventuous.Subscriptions/Diagnostics/CheckpointCommitMetrics.cs[12-37]
- src/Core/src/Eventuous.Subscriptions/Diagnostics/SubscriptionMetrics.cs[44-63]

### Suggested fix approach
Choose one (or combine):
1) **Keep ACK-path diagnostic emission without `_positions` access**
   - Have the worker maintain a thread-safe snapshot (e.g., boxed `CommitPosition?` or an atomic `ulong firstPendingSequence`) updated inside `Process()` / after `RemoveWhere`.
   - In `Commit()`, emit the diagnostic with `CommitPosition = position` and `FirstPending = snapshot` (no `SortedSet` reads on caller thread).
2) **Split diagnostics**
   - Emit a lightweight “ACK received” diagnostic from `Commit()` (no `_positions`), and a separate “pending range” diagnostic from `Process()`.
   - Update `CheckpointCommitMetrics` to use the ACK diagnostic for gap/time, and the worker diagnostic for pending-checkpoints.
3) **If the new semantics are intended**, update metric descriptions/documentation so `gap` and `time lag` explicitly reflect commit-worker progress rather than last processed/acked event.

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

var next = GetCommitPosition(false);

if (!next.Valid) return;
Expand All @@ -78,9 +87,9 @@ async ValueTask Process(IReadOnlyList<CommitPosition> list, CancellationToken ca
/// <param name="cancellationToken">Cancellation token</param>
/// <returns></returns>
[PublicAPI]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Diagnostics only")]
public ValueTask Commit(CommitPosition position, CancellationToken cancellationToken) {
if (Diagnostic.IsEnabled(CommitOperation)) Diagnostic.Write(CommitOperation, new CommitEvent(_subscriptionId, position, _positions.Min));
// No _positions access here — that read moved to the worker thread's Process (AI-1329). This
// runs on the ack caller thread and must never touch the worker-owned, non-thread-safe set.
position.LogContext?.PositionReceived(position);

return _worker.Write(position, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Eventuous.Diagnostics;
using Eventuous.Subscriptions.Checkpoints;
using Shouldly;

namespace Eventuous.Tests.Subscriptions;

/// <summary>
/// Guards against the production wedge (AI-1329) where <see cref="CheckpointCommitHandler.Commit"/>
/// read the non-thread-safe <c>_positions</c> <see cref="SortedSet{T}"/> ON THE ACK CALLER'S THREAD
/// (to build the commit diagnostic's <c>FirstPending</c>) while the commit worker thread mutated it —
/// a data race that threw <see cref="NullReferenceException"/> out of the ack path and dropped the
/// subscription. The commit diagnostic must be emitted from the worker thread, where <c>_positions</c>
/// is owned, so it can never race the caller.
/// </summary>
public class CheckpointCommitHandlerConcurrencyTests {
/// <summary>
/// Captures the managed thread id on which the commit diagnostic is emitted. Attaching any
/// listener with the commit-handler diagnostic name also flips <c>Diagnostic.IsEnabled("Commit")</c>
/// to true — exactly what <c>AddEventuousSubscriptions()</c> does in a real app, and why production
/// hit this while the test suite (no listener) never had.
/// </summary>
sealed class ThreadCapturingListener(string subscriptionId, TaskCompletionSource<int> emittedOn)
: GenericListener(CheckpointCommitHandler.DiagnosticName), IDisposable {
// The commit DiagnosticListener is process-global, so filter to OUR handler's subscription id
// (CommitEvent.Id) — otherwise a concurrently-running test's handler could be captured. The
// event type is internal, so read Id reflectively. Capture the thread DiagnosticSource.Write
// ran on; it is synchronous, so this is the emitting thread.
protected override void OnEvent(KeyValuePair<string, object?> evt) {
var id = evt.Value?.GetType().GetProperty("Id")?.GetValue(evt.Value) as string;

if (id == subscriptionId) emittedOn.TrySetResult(Environment.CurrentManagedThreadId);
}
}

[Test]
public async Task Commit_diagnostic_is_not_emitted_on_the_caller_thread(CancellationToken ct) {
var subscriptionId = $"test-commit-thread-{Guid.NewGuid():N}";
var emittedOn = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);

using var listener = new ThreadCapturingListener(subscriptionId, emittedOn);

var store = new NoOpCheckpointStore();

await using var handler = new CheckpointCommitHandler(subscriptionId, store, TimeSpan.FromMilliseconds(1), batchSize: 1);

// Call Commit from a DEDICATED thread. Its ManagedThreadId is never a thread-pool id, and the
// worker runs on the pool — so "emitted != caller" is deterministic (comparing two pool threads
// could collide under load). Before the fix Commit emits synchronously on this dedicated thread;
// after it, emission happens on the worker.
var callerThreadId = 0;
var caller = new Thread(() => {
callerThreadId = Environment.CurrentManagedThreadId;
handler.Commit(new CommitPosition(0, 0, DateTime.UtcNow), ct).AsTask().GetAwaiter().GetResult();
}) { IsBackground = true };
Comment on lines +51 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remediation recommended

1. Blocking getresult() in test 📘 Rule violation ☼ Reliability

The new concurrency test blocks on handler.Commit(...).AsTask().GetAwaiter().GetResult() which
violates the repo guidance to avoid blocking waits in async flows. Even though it’s test code, this
can hide deadlocks/hangs and conflicts with the async/.NoContext() convention.
Agent Prompt
## Issue description
`CheckpointCommitHandlerConcurrencyTests` blocks on an async operation via `GetAwaiter().GetResult()`, which conflicts with the repository’s async-first convention.

## Issue Context
This code is in a test, but blocking waits can still create brittle hangs/deadlock-like behavior and violates the compliance guidance to avoid sync-over-async.

## Fix Focus Areas
- src/Core/test/Eventuous.Tests.Subscriptions/CheckpointCommitHandlerConcurrencyTests.cs[51-54]

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

caller.Start();
caller.Join();

var emittedThreadId = await emittedOn.Task.WaitAsync(TimeSpan.FromSeconds(5), ct);

emittedThreadId.ShouldNotBe(
callerThreadId,
"the commit diagnostic reads the worker-owned _positions, so it must be emitted on the worker thread, never the ack caller thread"
);
}
}
Loading