-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/outbox wakeup signal #100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7775e9c
7062a3e
5ce17e0
c2e7f8b
28aeb17
bed2d21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| namespace BBT.Aether.Events; | ||
|
|
||
| /// <summary> | ||
| /// Publishes the <see cref="OutboxWakeupEvent"/> nudge. Implementations must be fire-and-forget | ||
| /// safe: a failed notify is swallowed by callers because polling backstops delivery. | ||
| /// </summary> | ||
| public interface IOutboxWakeupNotifier | ||
| { | ||
| Task NotifyAsync(CancellationToken cancellationToken = default); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| namespace BBT.Aether.Events; | ||
|
|
||
| /// <summary> | ||
| /// Loss-tolerant wake nudge published directly to pub/sub (never through the outbox) after a unit | ||
| /// of work that stored at least one outbox message commits. Subscribing outbox processors treat it | ||
| /// as "poll now"; the payload is deliberately empty and delivery is best-effort — the adaptive | ||
| /// polling interval remains the safety net for lost or early signals. | ||
| /// </summary> | ||
| [EventName("aether.outbox.wakeup")] | ||
| public sealed class OutboxWakeupEvent; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| using System; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| namespace BBT.Aether.Polling; | ||
|
|
||
| /// <summary> | ||
| /// A coalescing wake signal for adaptive polling loops. Producers call <see cref="Signal"/> when | ||
| /// new work becomes available; the polling loop awaits <see cref="WaitAsync"/> with its normal | ||
| /// interval as the timeout so a signal cuts the wait short while polling remains the safety net. | ||
| /// The marker type parameter distinguishes independent loops (e.g. outbox vs inbox) in DI. | ||
| /// </summary> | ||
| /// <typeparam name="TMarker">Marker type identifying the loop this signal wakes.</typeparam> | ||
| public interface IPollingWakeSignal<TMarker> | ||
| { | ||
| /// <summary>Wakes the loop. Multiple pending signals coalesce into one.</summary> | ||
| void Signal(); | ||
|
|
||
| /// <summary> | ||
| /// Waits until <see cref="Signal"/> is called or the timeout elapses. | ||
| /// Returns true when woken by a signal, false on timeout. | ||
| /// </summary> | ||
| Task<bool> WaitAsync(TimeSpan timeout, CancellationToken cancellationToken = default); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| using System; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| namespace BBT.Aether.Polling; | ||
|
|
||
| /// <summary> | ||
| /// Default <see cref="IPollingWakeSignal{TMarker}"/> over a bounded <see cref="SemaphoreSlim"/>(0,1): | ||
| /// signals coalesce (a second Signal while one is pending is a no-op), so a burst of producers | ||
| /// causes exactly one early wake. | ||
| /// </summary> | ||
| public sealed class PollingWakeSignal<TMarker> : IPollingWakeSignal<TMarker> | ||
| { | ||
| private readonly SemaphoreSlim _semaphore = new(0, 1); | ||
|
|
||
| public void Signal() | ||
| { | ||
| try | ||
| { | ||
| _semaphore.Release(); | ||
| } | ||
| catch (SemaphoreFullException) | ||
| { | ||
| // A wake is already pending — coalesce. | ||
| } | ||
| } | ||
|
|
||
| public Task<bool> WaitAsync(TimeSpan timeout, CancellationToken cancellationToken = default) | ||
| => _semaphore.WaitAsync(timeout, cancellationToken); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Dapr.Client; | ||
|
|
||
| namespace BBT.Aether.Events; | ||
|
|
||
| /// <summary> | ||
| /// Publishes <see cref="OutboxWakeupEvent"/> straight to the configured pub/sub component, | ||
| /// bypassing the outbox by design (the nudge must not create the work it announces). | ||
| /// </summary> | ||
| public sealed class DaprOutboxWakeupNotifier( | ||
| DaprClient daprClient, | ||
| ITopicNameStrategy topicNameStrategy, | ||
| AetherEventBusOptions eventBusOptions) : IOutboxWakeupNotifier | ||
| { | ||
| private readonly string _topic = topicNameStrategy.GetTopicName(typeof(OutboxWakeupEvent)); | ||
|
|
||
| public Task NotifyAsync(CancellationToken cancellationToken = default) | ||
| => daprClient.PublishEventAsync( | ||
| eventBusOptions.PubSubName, | ||
| _topic, | ||
| new OutboxWakeupEvent(), | ||
| cancellationToken); | ||
|
Comment on lines
+19
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): Triggers: When the wakeup topic is delivered through the framework's standard Dapr event-bus subscription pipeline. Suggested fix: Publish the wakeup using the same CloudEvent envelope serialization and metadata path as |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| using System; | ||
| using System.Diagnostics; | ||
| using System.Runtime.CompilerServices; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using BBT.Aether.Uow; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| namespace BBT.Aether.Events; | ||
|
|
||
| /// <summary> | ||
| /// Decides when the outbox wakeup nudge fires: once per unit of work that stored at least one | ||
| /// outbox message, from the UoW's OnCompleted callback — but WITHOUT extending the commit path: | ||
| /// the callback returns immediately and the pub/sub publish runs as an unobserved task with a | ||
| /// bounded timeout. A lost or failed nudge is logged and absorbed by the polling safety net. | ||
| /// </summary> | ||
| public sealed class OutboxWakeupCoordinator( | ||
| AetherOutboxOptions options, | ||
| IUnitOfWorkManager? unitOfWorkManager = null, | ||
| IOutboxWakeupNotifier? wakeupNotifier = null, | ||
| ILogger<OutboxWakeupCoordinator>? logger = null) | ||
| { | ||
| private static readonly TimeSpan NotifyTimeout = TimeSpan.FromSeconds(2); | ||
| private static readonly ConditionalWeakTable<IUnitOfWork, object> WakeupRegistered = new(); | ||
| private static readonly object RegisteredSentinel = new(); | ||
|
|
||
| /// <summary>Call once per stored outbox message; registration collapses to one per UoW.</summary> | ||
| public void OnOutboxMessageStored() | ||
| { | ||
| if (wakeupNotifier is null || !options.WakeupSignalEnabled) | ||
| return; | ||
|
|
||
| var uow = unitOfWorkManager?.Current; | ||
| if (uow is null) | ||
| { | ||
| // No ambient UoW: the caller flushes on its own SaveChanges, which this coordinator | ||
| // cannot observe — the nudge may land BEFORE the row is visible. This branch is an | ||
| // early best-effort hint EXCLUDED from the latency guarantee (the row then waits for | ||
| // normal polling). Every vnext transition path runs with an ambient UoW, so this is | ||
| // never the latency-critical path. | ||
| NotifyFireAndForget(); | ||
| return; | ||
| } | ||
|
|
||
| // Dedupe on the shared transaction root, not the per-call scope object. A `Required` | ||
| // participant scope (UnitOfWorkScope with ownsRoot == false) is a distinct object per | ||
| // nesting level that forwards OnCompleted to the same CompositeUnitOfWork root — keying | ||
| // on `uow` itself would register (and later fire) once per nested scope for a single | ||
| // commit. CompositeUnitOfWork itself already implements IUnitOfWork, so it is a valid | ||
| // ConditionalWeakTable key; a `uow` that is not a UnitOfWorkScope (e.g. the root itself, | ||
| // or a test substitute) dedupes on itself as before. | ||
| var dedupeKey = (uow as UnitOfWorkScope)?.SharedRoot ?? uow; | ||
|
|
||
| lock (RegisteredSentinel) | ||
| { | ||
| if (WakeupRegistered.TryGetValue(dedupeKey, out _)) | ||
| return; | ||
| WakeupRegistered.Add(dedupeKey, RegisteredSentinel); | ||
| } | ||
|
|
||
| // OnCompleted callbacks are awaited inside CommitAsync — return a completed task and let | ||
| // the publish run detached so a slow sidecar can never stretch the commit path. | ||
| uow.OnCompleted(_ => | ||
| { | ||
| NotifyFireAndForget(); | ||
| return Task.CompletedTask; | ||
| }); | ||
| } | ||
|
|
||
| private void NotifyFireAndForget() | ||
| { | ||
| _ = Task.Run(async () => | ||
| { | ||
| // The nudge is infrastructure, not business flow: sever the ambient Activity captured via | ||
| // ExecutionContext so the publish's client span (and the delivery it causes on the worker) | ||
| // can never attach to — or propagate the traceparent of — the committing business trace. | ||
| Activity.Current = null; | ||
| try | ||
| { | ||
| using var cts = new CancellationTokenSource(NotifyTimeout); | ||
| await wakeupNotifier!.NotifyAsync(cts.Token); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| logger?.LogWarning(ex, "Outbox wakeup nudge failed or timed out; polling will pick the work up"); | ||
| } | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (broader_impact): The outbox wakeup event is published and the polling signal is registered, but no
IEventHandler<OutboxWakeupEvent>or equivalent subscription forwards deliveries toIPollingWakeSignal<IOutboxProcessor>.Signal(). Consequently, successful wakeup publishes are consumed by no in-process component and outbox workers continue waiting for their normal polling interval.Triggers: When
WakeupSignalEnabledis true and an outbox message is committed.Suggested fix: Add and register an
IEventHandler<OutboxWakeupEvent>that resolvesIPollingWakeSignal<IOutboxProcessor>and callsSignal().