diff --git a/src/Mocha/src/Mocha.Transport.InMemory/MessageBusBuilderExtensions.cs b/src/Mocha/src/Mocha.Transport.InMemory/MessageBusBuilderExtensions.cs index 4a31248c25b..3c564b6a80b 100644 --- a/src/Mocha/src/Mocha.Transport.InMemory/MessageBusBuilderExtensions.cs +++ b/src/Mocha/src/Mocha.Transport.InMemory/MessageBusBuilderExtensions.cs @@ -1,4 +1,10 @@ -namespace Mocha.Transport.InMemory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Mocha.Scheduling; +using Mocha.Transport.InMemory.Scheduling; + +namespace Mocha.Transport.InMemory; /// /// Extension methods for registering the in-memory messaging transport on an . @@ -19,6 +25,28 @@ public static IMessageBusHostBuilder AddInMemory( busBuilder.ConfigureMessageBus(b => b.AddTransport(transport)); + busBuilder.Services.TryAddSingleton( + sp => new InMemoryTransportScheduledMessageStore( + sp.GetService() ?? TimeProvider.System)); + + busBuilder.Services.AddSingleton( + new ScheduledMessageStoreRegistration( + transport, + InMemoryTransportScheduledMessageStore.TokenPrefix, + static sp => sp.GetRequiredService())); + + busBuilder.Services.TryAddSingleton( + sp => new InMemoryScheduledMessageWorker( + sp, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetService() ?? TimeProvider.System, + sp.GetRequiredService>())); + + busBuilder.Services.AddHostedService( + static sp => sp.GetRequiredService()); + return busBuilder; } diff --git a/src/Mocha/src/Mocha.Transport.InMemory/Mocha.Transport.InMemory.csproj b/src/Mocha/src/Mocha.Transport.InMemory/Mocha.Transport.InMemory.csproj index ba20a2a0f7c..77acfb86aea 100644 --- a/src/Mocha/src/Mocha.Transport.InMemory/Mocha.Transport.InMemory.csproj +++ b/src/Mocha/src/Mocha.Transport.InMemory/Mocha.Transport.InMemory.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Mocha/src/Mocha.Transport.InMemory/Scheduling/InMemoryScheduledMessageWorker.cs b/src/Mocha/src/Mocha.Transport.InMemory/Scheduling/InMemoryScheduledMessageWorker.cs new file mode 100644 index 00000000000..5c1cbf60caa --- /dev/null +++ b/src/Mocha/src/Mocha.Transport.InMemory/Scheduling/InMemoryScheduledMessageWorker.cs @@ -0,0 +1,217 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ObjectPool; +using Mocha.Middlewares; +using Mocha.Outbox; +using Mocha.Scheduling; +using Mocha.Threading; + +namespace Mocha.Transport.InMemory.Scheduling; + +/// +/// A hosted background worker that drains due entries from the in-memory scheduled message store and +/// dispatches them through the normal endpoint pipeline. It sleeps on the store's dedicated signal +/// until the next entry is due. +/// +internal sealed class InMemoryScheduledMessageWorker( + IServiceProvider services, + IMessagingRuntime runtime, + IMessagingPools pools, + InMemoryTransportScheduledMessageStore store, + TimeProvider timeProvider, + ILogger logger) + : IHostedService, IAsyncDisposable +{ + private readonly ObjectPool _contextPool = pools.DispatchContext; + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private ContinuousTask? _task; + + public Task StartAsync(CancellationToken cancellationToken) + { + lock (_lock) + { + if (_task is not null) + { + return Task.CompletedTask; + } + + _task = new ContinuousTask(ProcessAsync); + } + + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + ContinuousTask? task; + + lock (_lock) + { + task = _task; + _task = null; + } + + if (task is not null) + { + await task.DisposeAsync(); + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync(CancellationToken.None); + } + + private async Task ProcessAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + var now = timeProvider.GetUtcNow(); + + if (store.TryTakeDue(now, out var entry)) + { + await DispatchAsync(entry, cancellationToken); + + continue; + } + + var wakeTime = store.NextDueTime() ?? DateTimeOffset.MaxValue; + + await store.Signal.WaitUntilAsync(wakeTime, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Normal shutdown. + } + } + } + + private async Task DispatchAsync(ScheduledEntry entry, CancellationToken cancellationToken) + { + var envelope = entry.Envelope; + var messageType = GetMessageType(envelope.MessageType); + var isReply = envelope.Headers?.IsReply() ?? false; + var endpoint = isReply + ? GetReplyDispatchEndpoint(envelope.DestinationAddress) + : GetDispatchEndpoint(envelope.DestinationAddress); + + if (messageType is null || endpoint is null) + { + logger.CouldNotResolveScheduledMessage(entry.Id); + + return; + } + + Activity? activity = null; + var traceparent = envelope.Headers?.Get(MessageHeaders.Traceparent); + + if (!string.IsNullOrEmpty(traceparent)) + { + var tracestate = envelope.Headers?.Get(MessageHeaders.Tracestate); + if (ActivityContext.TryParse(traceparent, tracestate, out var parentContext)) + { + activity = OpenTelemetry.Source.CreateActivity( + "scheduler send", + ActivityKind.Client, + parentContext); + + activity?.SetMessageId(envelope.MessageId); + + activity?.Start(); + } + } + + var context = _contextPool.Get(); + try + { + await using var scope = services.CreateAsyncScope(); + + context.Initialize(scope.ServiceProvider, endpoint, runtime, messageType, cancellationToken); + context.SkipScheduler(); + context.SkipOutbox(); + context.Envelope = envelope; + + await endpoint.ExecuteAsync(context); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The worker is shutting down; the in-flight message is lost (in-memory is non-durable). + } + catch (Exception ex) + { + logger.ScheduledMessageDispatchFailed(entry.Id, ex); + } + finally + { + _contextPool.Return(context); + activity?.Dispose(); + } + } + + private MessageType? GetMessageType(string? messageType) + { + try + { + return messageType is null ? null : runtime.Messages.GetMessageType(messageType); + } + catch + { + return null; + } + } + + private DispatchEndpoint? GetReplyDispatchEndpoint(string? destinationAddress) + { + try + { + if (!Uri.TryCreate(destinationAddress, UriKind.Absolute, out var uri)) + { + return null; + } + + return runtime.GetTransport(uri)?.ReplyDispatchEndpoint; + } + catch + { + return null; + } + } + + private DispatchEndpoint? GetDispatchEndpoint(string? destinationAddress) + { + try + { + if (destinationAddress is null || !Uri.TryCreate(destinationAddress, UriKind.Absolute, out var uri)) + { + return null; + } + + return runtime.GetDispatchEndpoint(uri); + } + catch + { + return null; + } + } +} + +internal static partial class InMemorySchedulerLogs +{ + [LoggerMessage( + 1, + LogLevel.Critical, + "Could not resolve the message type or endpoint for scheduled message {Id}. Message dropped.")] + public static partial void CouldNotResolveScheduledMessage(this ILogger logger, Guid id); + + [LoggerMessage(2, LogLevel.Error, "Failed to dispatch scheduled message {Id}. Message dropped.")] + public static partial void ScheduledMessageDispatchFailed(this ILogger logger, Guid id, Exception exception); +} diff --git a/src/Mocha/src/Mocha.Transport.InMemory/Scheduling/InMemoryTransportScheduledMessageStore.cs b/src/Mocha/src/Mocha.Transport.InMemory/Scheduling/InMemoryTransportScheduledMessageStore.cs new file mode 100644 index 00000000000..0a4c0c9fec6 --- /dev/null +++ b/src/Mocha/src/Mocha.Transport.InMemory/Scheduling/InMemoryTransportScheduledMessageStore.cs @@ -0,0 +1,181 @@ +using System.Diagnostics.CodeAnalysis; +using Mocha.Middlewares; +using Mocha.Scheduling; + +namespace Mocha.Transport.InMemory.Scheduling; + +/// +/// An in-process for the in-memory transport. Holds scheduled +/// envelopes ordered by due time and signals a dedicated worker when the earliest due time changes. +/// +internal sealed class InMemoryTransportScheduledMessageStore(TimeProvider timeProvider) + : IScheduledMessageStore, IDisposable +{ + /// + /// The opaque-token prefix identifying scheduling tokens owned by this store. + /// + public const string TokenPrefix = "in-memory-transport:"; + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + private readonly SortedSet _ordered = new(ScheduledEntryComparer.Instance); + private readonly Dictionary _byId = []; + private readonly MessageBusSchedulerSignal _signal = new(timeProvider); + + /// + /// Gets the dedicated signal the worker waits on. Not the shared scheduler singleton. + /// + public ISchedulerSignal Signal => _signal; + + public ValueTask PersistAsync(IDispatchContext context, CancellationToken cancellationToken) + { + if (context.Envelope is not { } envelope) + { + throw new InvalidOperationException( + "The dispatch context does not carry a serialized envelope."); + } + + if (context.ScheduledTime is not { } scheduledTime) + { + throw new InvalidOperationException( + "The dispatch context does not carry a scheduled time."); + } + + var token = Add(envelope, scheduledTime); + + return new ValueTask(token); + } + + /// + /// Deep-copies the envelope and stores it under a new token. The stored copy is independent of + /// the caller's envelope, so mutating the original body or headers afterward does not affect it. + /// + internal string Add(MessageEnvelope envelope, DateTimeOffset scheduledTime) + { + var id = Guid.NewGuid(); + var copy = new MessageEnvelope(envelope) { Body = envelope.Body.ToArray() }; + var entry = new ScheduledEntry(id, copy, scheduledTime); + + lock (_lock) + { + _ordered.Add(entry); + _byId[id] = entry; + } + + _signal.Notify(scheduledTime); + + return TokenPrefix + id.ToString("D"); + } + + public ValueTask CancelAsync(string token, CancellationToken cancellationToken) + { + if (!token.StartsWith(TokenPrefix, StringComparison.Ordinal) + || !Guid.TryParse(token.AsSpan(TokenPrefix.Length), out var id)) + { + return new ValueTask(false); + } + + lock (_lock) + { + if (_byId.Remove(id, out var entry)) + { + _ordered.Remove(entry); + + return new ValueTask(true); + } + } + + return new ValueTask(false); + } + + /// + /// Atomically removes and returns the earliest entry whose scheduled time is at or before + /// , if any. + /// + public bool TryTakeDue(DateTimeOffset now, [NotNullWhen(true)] out ScheduledEntry? entry) + { + lock (_lock) + { + if (_ordered.Count > 0) + { + var min = _ordered.Min!; + + if (min.ScheduledTime <= now) + { + _ordered.Remove(min); + _byId.Remove(min.Id); + entry = min; + + return true; + } + } + } + + entry = null; + + return false; + } + + /// + /// Gets the earliest scheduled time currently held, or null if the store is empty. + /// + public DateTimeOffset? NextDueTime() + { + lock (_lock) + { + return _ordered.Count > 0 ? _ordered.Min!.ScheduledTime : null; + } + } + + public void Dispose() + { + _signal.Dispose(); + } +} + +/// +/// A scheduled envelope held by the in-memory store. +/// +internal sealed class ScheduledEntry(Guid id, MessageEnvelope envelope, DateTimeOffset scheduledTime) +{ + public Guid Id { get; } = id; + + public MessageEnvelope Envelope { get; } = envelope; + + public DateTimeOffset ScheduledTime { get; } = scheduledTime; +} + +/// +/// Orders entries by scheduled time, then by ID, so distinct entries with the same due time coexist +/// in the (a total order is required, otherwise adds are dropped). +/// +internal sealed class ScheduledEntryComparer : IComparer +{ + public static readonly ScheduledEntryComparer Instance = new(); + + public int Compare(ScheduledEntry? x, ScheduledEntry? y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + if (x is null) + { + return -1; + } + + if (y is null) + { + return 1; + } + + var byTime = x.ScheduledTime.CompareTo(y.ScheduledTime); + + return byTime != 0 ? byTime : x.Id.CompareTo(y.Id); + } +} diff --git a/src/Mocha/src/Mocha/Scheduling/MessageBusSchedulerSignal.cs b/src/Mocha/src/Mocha/Scheduling/MessageBusSchedulerSignal.cs index 410761b3ef8..8be0204e0aa 100644 --- a/src/Mocha/src/Mocha/Scheduling/MessageBusSchedulerSignal.cs +++ b/src/Mocha/src/Mocha/Scheduling/MessageBusSchedulerSignal.cs @@ -10,18 +10,23 @@ internal sealed class MessageBusSchedulerSignal(TimeProvider timeProvider) private DateTimeOffset _target = DateTimeOffset.MaxValue; private CancellationTokenSource? _delayCts; + private bool _notified; + private bool _isWaiting; /// public void Notify(DateTimeOffset scheduledTime) { lock (_lock) { - if (scheduledTime >= _target) + // An active wait wakes no later than its target, so a notify for an equal or later time + // is already covered. When no wait is active the target is stale, so any notify must be + // recorded to force the next wait to re-evaluate rather than sleep. + if (_isWaiting && scheduledTime >= _target) { return; } - _target = scheduledTime; + _notified = true; _delayCts?.Cancel(); } } @@ -33,32 +38,47 @@ public async Task WaitUntilAsync(DateTimeOffset wakeTime, CancellationToken canc lock (_lock) { + if (_notified) + { + // A notify arrived before this wait began. Consume it and return at once so the + // caller re-evaluates its next due time instead of sleeping. + _notified = false; + + return; + } + _target = wakeTime; + _isWaiting = true; _delayCts?.Dispose(); _delayCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); delayCts = _delayCts; } - var delay = wakeTime - timeProvider.GetUtcNow(); - - if (delay <= TimeSpan.Zero) + try { - return; - } + var delay = wakeTime - timeProvider.GetUtcNow(); - if (delay > s_maxDelay) - { - delay = s_maxDelay; - } + if (delay > TimeSpan.Zero) + { + if (delay > s_maxDelay) + { + delay = s_maxDelay; + } - try - { - await Task.Delay(delay, timeProvider, delayCts.Token); + await Task.Delay(delay, timeProvider, delayCts.Token); + } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { // Woken by Notify - return to dispatcher, which will re-query and re-sleep. } + finally + { + lock (_lock) + { + _isWaiting = false; + } + } } /// diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresSchedulingIntegrationTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresSchedulingIntegrationTests.cs index 5ecadfa63a5..bd4a5c04609 100644 --- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresSchedulingIntegrationTests.cs +++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresSchedulingIntegrationTests.cs @@ -85,7 +85,11 @@ public async Task Scheduler_Should_ProcessPendingMessages_When_WorkerStartsAfter var builder = services.AddMessageBus(); builder.AddEntityFramework(ef => ef.UsePostgresScheduling()); builder.AddEventHandler(); - builder.AddInMemory(); + + // Use the in-memory transport without its native scheduling store so the EF Core + // Postgres fallback (UsePostgresScheduling) is the store that handles scheduled dispatch. + var transport = new InMemoryMessagingTransport(static _ => { }); + builder.ConfigureMessageBus(b => b.AddTransport(transport)); var provider = services.BuildServiceProvider(); var runtime = (MessagingRuntime)provider.GetRequiredService(); @@ -128,9 +132,10 @@ await recorder.WaitAsync(s_timeout, count), } finally { - foreach (var svc in hostedServices) + // Stop hosted services in reverse registration order, matching IHost shutdown. + for (var i = hostedServices.Count - 1; i >= 0; i--) { - await svc.StopAsync(TestContext.Current.CancellationToken); + await hostedServices[i].StopAsync(TestContext.Current.CancellationToken); } // Allow in-flight processor transactions to drain (see TestEnvironment comment) @@ -506,7 +511,11 @@ private async Task CreateBusWithSchedulingAsync( var builder = services.AddMessageBus(); builder.AddEntityFramework(ef => ef.UsePostgresScheduling()); builder.AddEventHandler(); - builder.AddInMemory(); + + // Use the in-memory transport without its native scheduling store so the EF Core + // Postgres fallback (UsePostgresScheduling) is the store that handles scheduled dispatch. + var transport = new InMemoryMessagingTransport(static _ => { }); + builder.ConfigureMessageBus(b => b.AddTransport(transport)); configure?.Invoke(builder); @@ -681,9 +690,10 @@ private sealed class TestEnvironment(ServiceProvider provider, List= 0; i--) { - await svc.StopAsync(default); + await hostedServices[i].StopAsync(default); } // ContinuousTask.DisposeAsync cancels but doesn't await the background diff --git a/src/Mocha/test/Mocha.Sagas.Tests/IntegrationTests.cs b/src/Mocha/test/Mocha.Sagas.Tests/IntegrationTests.cs index b7238e60f67..cdc0396bd15 100644 --- a/src/Mocha/test/Mocha.Sagas.Tests/IntegrationTests.cs +++ b/src/Mocha/test/Mocha.Sagas.Tests/IntegrationTests.cs @@ -1,7 +1,5 @@ using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; -using Mocha.Middlewares; -using Mocha.Scheduling; using Mocha.Transport.InMemory; namespace Mocha.Sagas.Tests; @@ -108,12 +106,6 @@ public async Task Saga_Should_TimeoutWithCustomResponse() await using var provider = await CreateBusAsync(b => { b.Services.AddSingleton(recorder); - b.Services.AddSingleton(); - b.Services.AddSingleton(new ScheduledMessageStoreRegistration( - transportType: null, - tokenPrefix: "test:", - storeType: typeof(TestScheduledMessageStore), - isFallback: true)); b.AddEventHandler(); b.AddSaga(); }); @@ -389,17 +381,6 @@ protected override void Configure(ISagaDescriptor descriptor) } } - private sealed class TestScheduledMessageStore : IScheduledMessageStore - { - private int _tokens; - - public ValueTask PersistAsync(IDispatchContext context, CancellationToken cancellationToken) - => ValueTask.FromResult($"test:{Interlocked.Increment(ref _tokens)}"); - - public ValueTask CancelAsync(string token, CancellationToken cancellationToken) - => ValueTask.FromResult(true); - } - /// /// Timeout saga with Timeout() API: StartTimeoutEvent -> Active (publishes TriggerEvent) -> /// SagaTimedOutEvent -> TimedOut (final, with custom response possible) diff --git a/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulerSignalTests.cs b/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulerSignalTests.cs index 52bee18dbba..798a37444af 100644 --- a/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulerSignalTests.cs +++ b/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulerSignalTests.cs @@ -158,4 +158,53 @@ public async Task Notify_Should_NotWake_When_ScheduledTimeEqualsCurrentTarget() Assert.Same(waitTask, completed); Assert.True(cts.IsCancellationRequested); } + + [Fact] + public async Task WaitUntilAsync_Should_ReturnImmediately_When_NotifyRacedBeforeWait() + { + // arrange + // a Notify that lowers the target arrives before the waiter calls WaitUntilAsync (the worker + // read an empty or later next-due time, then an earlier message was scheduled). The pending + // notify must not be lost: the next wait returns at once so the caller re-evaluates. + var timeProvider = new FakeTimeProvider(s_baseTime); + using var signal = new MessageBusSchedulerSignal(timeProvider); + signal.Notify(s_baseTime.AddSeconds(1)); + + // act - wait with a far-future wake time, as the worker would after seeing no due message + var waitTask = signal.WaitUntilAsync(DateTimeOffset.MaxValue, CancellationToken.None); + + // assert - it returns without sleeping to the cap; time is never advanced in this test + Assert.True( + waitTask.IsCompleted, + "WaitUntilAsync must not lose a notify that arrived before the wait"); + await waitTask; + } + + [Fact] + public async Task WaitUntilAsync_Should_ReturnImmediately_When_NotifiedAfterEarlierWaitCompleted() + { + // arrange + // a first wait for an earlier time completes, leaving a stale target behind. A later message + // is then scheduled before the next wait starts. The stale target must not swallow the notify: + // when no wait is active, any notify must force the next wait to re-evaluate. + var timeProvider = new FakeTimeProvider(s_baseTime); + using var signal = new MessageBusSchedulerSignal(timeProvider); + + var firstWait = signal.WaitUntilAsync(s_baseTime.AddMinutes(1), CancellationToken.None); + await Task.Delay(50, TestContext.Current.CancellationToken); + timeProvider.Advance(TimeSpan.FromMinutes(1)); + await firstWait; + + // a message scheduled for later than the now-stale earlier target + signal.Notify(timeProvider.GetUtcNow().AddMinutes(1)); + + // act - wait with a far-future wake time, as the worker would after seeing no due message + var secondWait = signal.WaitUntilAsync(DateTimeOffset.MaxValue, CancellationToken.None); + + // assert + Assert.True( + secondWait.IsCompleted, + "A notify after an earlier wait completed must not be swallowed by a stale target"); + await secondWait; + } } diff --git a/src/Mocha/test/Mocha.Tests/Scheduling/SchedulingMiddlewareIntegrationTests.cs b/src/Mocha/test/Mocha.Tests/Scheduling/SchedulingMiddlewareIntegrationTests.cs index a3a668cacbc..c3f561f86a2 100644 --- a/src/Mocha/test/Mocha.Tests/Scheduling/SchedulingMiddlewareIntegrationTests.cs +++ b/src/Mocha/test/Mocha.Tests/Scheduling/SchedulingMiddlewareIntegrationTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Time.Testing; using Mocha.Middlewares; using Mocha.Scheduling; @@ -198,7 +199,8 @@ public async Task SchedulePublishAsync_Should_ThrowNotSupported_When_NoStoreRegi services.AddSingleton(timeProvider); var builder = services.AddMessageBus(); - builder.AddInMemory(); + var transport = new InMemoryMessagingTransport(static _ => { }); + builder.ConfigureMessageBus(b => b.AddTransport(transport)); var provider = services.BuildServiceProvider(); var runtime = (MessagingRuntime)provider.GetRequiredService(); @@ -227,7 +229,8 @@ public async Task Scheduling_Should_ThrowNotSupported_When_NoStoreRegistered() var builder = services.AddMessageBus(); builder.AddEventHandler(); - builder.AddInMemory(); + var transport = new InMemoryMessagingTransport(static _ => { }); + builder.ConfigureMessageBus(b => b.AddTransport(transport)); var provider = services.BuildServiceProvider(); var runtime = (MessagingRuntime)provider.GetRequiredService(); @@ -260,14 +263,10 @@ private static async Task CreateBusWithSchedulingAsync( Action configure, TimeProvider? timeProvider = null) { + var transport = new InMemoryMessagingTransport(static _ => { }); var services = new ServiceCollection(); - services.AddScoped(_ => store); services.AddSingleton( - new ScheduledMessageStoreRegistration( - typeof(InMemoryMessagingTransport), - InMemoryScheduledMessageStore.TokenPrefix, - typeof(InMemoryScheduledMessageStore))); - services.AddSingleton(); + new ScheduledMessageStoreRegistration(transport, InMemoryScheduledMessageStore.TokenPrefix, _ => store)); if (timeProvider is not null) { @@ -275,9 +274,9 @@ private static async Task CreateBusWithSchedulingAsync( } var builder = services.AddMessageBus(); - builder.UseSchedulerCore(); configure(builder); - builder.AddInMemory(); + services.TryAddSingleton(); + builder.ConfigureMessageBus(b => b.AddTransport(transport)); var provider = services.BuildServiceProvider(); var runtime = (MessagingRuntime)provider.GetRequiredService(); diff --git a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Helpers/MessageBusBuilder.cs b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Helpers/MessageBusBuilder.cs index 51940aa4ba9..bef72cdba90 100644 --- a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Helpers/MessageBusBuilder.cs +++ b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Helpers/MessageBusBuilder.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Mocha.Transport.InMemory.Tests.Helpers; @@ -9,6 +10,12 @@ public static async Task BuildServiceProvider(this IMessageBusH var provider = builder.Services.BuildServiceProvider(); var runtime = (MessagingRuntime)provider.GetRequiredService(); await runtime.StartAsync(CancellationToken.None); + + foreach (var hostedService in provider.GetServices()) + { + await hostedService.StartAsync(CancellationToken.None); + } + return provider; } diff --git a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj index ac0279a43ef..69b828d5240 100644 --- a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj +++ b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Mocha.Transport.InMemory.Tests.csproj @@ -10,6 +10,10 @@ + + + + diff --git a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Scheduling/InMemorySchedulingTests.cs b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Scheduling/InMemorySchedulingTests.cs new file mode 100644 index 00000000000..e6e101295ad --- /dev/null +++ b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Scheduling/InMemorySchedulingTests.cs @@ -0,0 +1,259 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; + +namespace Mocha.Transport.InMemory.Tests.Scheduling; + +public class InMemorySchedulingTests +{ + private static readonly TimeSpan s_deliveryTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_notDeliveredWindow = TimeSpan.FromMilliseconds(500); + private static readonly DateTimeOffset s_start = new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task ScheduledMessage_Should_NotBeDelivered_When_BeforeDueTime() + { + // arrange + var recorder = new MessageRecorder(); + var time = new FakeTimeProvider(s_start); + await using var env = await CreateBusAsync(recorder, time); + + // act + await PublishScheduledAsync(env.Provider, s_start.AddMinutes(10)); + + // assert + Assert.False( + await recorder.WaitAsync(s_notDeliveredWindow), + "Message must not be delivered before its due time"); + } + + [Fact] + public async Task ScheduledMessage_Should_BeDelivered_When_DueTimeReached() + { + // arrange + var recorder = new MessageRecorder(); + var time = new FakeTimeProvider(s_start); + await using var env = await CreateBusAsync(recorder, time); + await PublishScheduledAsync(env.Provider, s_start.AddMinutes(10)); + + // act + time.Advance(TimeSpan.FromMinutes(11)); + + // assert + Assert.True( + await recorder.WaitAsync(s_deliveryTimeout), + "Message must be delivered once its due time is reached"); + var received = Assert.Single(recorder.Messages.OfType()); + Assert.Equal("ping", received.Text); + } + + [Fact] + public async Task CancelScheduledMessage_Should_PreventDelivery_When_BeforeDueTime() + { + // arrange + var recorder = new MessageRecorder(); + var time = new FakeTimeProvider(s_start); + await using var env = await CreateBusAsync(recorder, time); + var token = await ScheduleCancellableAsync(env.Provider, s_start.AddMinutes(10)); + + // act + using (var scope = env.Provider.CreateScope()) + { + var bus = scope.ServiceProvider.GetRequiredService(); + var cancelled = await bus.CancelScheduledMessageAsync( + token, + TestContext.Current.CancellationToken); + Assert.True(cancelled); + } + + time.Advance(TimeSpan.FromMinutes(11)); + + // assert + Assert.False( + await recorder.WaitAsync(s_notDeliveredWindow), + "Cancelled message must never be delivered"); + } + + [Fact] + public async Task ScheduledMessage_Should_PreserveBody_When_PooledContextReusedBeforeDue() + { + // arrange + // schedule one message, then dispatch another immediately so the pooled dispatch context is + // reused; the scheduled body must survive because the store deep-copied it. + var recorder = new MessageRecorder(); + var time = new FakeTimeProvider(s_start); + await using var env = await CreateBusAsync(recorder, time); + + using (var scope = env.Provider.CreateScope()) + { + var bus = scope.ServiceProvider.GetRequiredService(); + await bus.PublishAsync( + new ScheduledPing { Text = "scheduled" }, + new PublishOptions { ScheduledTime = s_start.AddMinutes(10) }, + TestContext.Current.CancellationToken); + await bus.PublishAsync( + new ScheduledPing { Text = "immediate" }, + TestContext.Current.CancellationToken); + } + + Assert.True( + await recorder.WaitAsync(s_deliveryTimeout), + "The immediate message should be delivered"); + + // act + time.Advance(TimeSpan.FromMinutes(11)); + + // assert + Assert.True( + await recorder.WaitAsync(s_deliveryTimeout), + "The scheduled message should be delivered after its due time"); + var texts = recorder.Messages + .OfType() + .Select(p => p.Text) + .Order() + .ToList(); + Assert.Equal(["immediate", "scheduled"], texts); + } + + [Fact] + public async Task ScheduledMessage_Should_BeDroppedAndWorkerContinue_When_DispatchThrows() + { + // arrange + // a dispatch middleware throws on the first re-dispatch; the worker must log-and-drop that + // message and keep delivering later scheduled messages. + var recorder = new MessageRecorder(); + var time = new FakeTimeProvider(s_start); + var toggle = new DispatchFailureToggle(); + await using var env = await CreateBusAsync( + recorder, + time, + b => AddThrowOnceDispatchMiddleware(b, toggle)); + + await PublishScheduledAsync(env.Provider, s_start.AddMinutes(5), "dropped"); + time.Advance(TimeSpan.FromMinutes(6)); + + await PublishScheduledAsync(env.Provider, s_start.AddMinutes(10), "delivered"); + + // act + time.Advance(TimeSpan.FromMinutes(6)); + + // assert - only the second message is delivered; the worker survived the failure + Assert.True( + await recorder.WaitAsync(s_deliveryTimeout), + "The worker should survive a dispatch failure and deliver later messages"); + var received = Assert.Single(recorder.Messages.OfType()); + Assert.Equal("delivered", received.Text); + } + + private static async Task PublishScheduledAsync( + IServiceProvider provider, + DateTimeOffset when, + string text = "ping") + { + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + await bus.PublishAsync( + new ScheduledPing { Text = text }, + new PublishOptions { ScheduledTime = when }, + TestContext.Current.CancellationToken); + } + + private static async Task ScheduleCancellableAsync(IServiceProvider provider, DateTimeOffset when) + { + using var scope = provider.CreateScope(); + var bus = scope.ServiceProvider.GetRequiredService(); + var result = await bus.SchedulePublishAsync( + new ScheduledPing { Text = "ping" }, + when, + TestContext.Current.CancellationToken); + + return result.Token!; + } + + private static async Task CreateBusAsync( + MessageRecorder recorder, + FakeTimeProvider time, + Action? configure = null) + { + var services = new ServiceCollection(); + services.AddSingleton(recorder); + services.AddLogging(); + services.AddSingleton(time); + + var builder = services.AddMessageBus(); + builder.AddEventHandler(); + builder.AddInMemory(); + configure?.Invoke(builder); + + var provider = services.BuildServiceProvider(); + var runtime = (MessagingRuntime)provider.GetRequiredService(); + await runtime.StartAsync(TestContext.Current.CancellationToken); + + var hostedServices = provider.GetServices().ToList(); + foreach (var svc in hostedServices) + { + await svc.StartAsync(TestContext.Current.CancellationToken); + } + + return new TestEnvironment(provider, hostedServices); + } + + private static void AddThrowOnceDispatchMiddleware( + IMessageBusHostBuilder builder, + DispatchFailureToggle toggle) + { + builder.ConfigureMessageBus(h => + h.UseDispatch( + new DispatchMiddlewareConfiguration( + (_, next) => ctx => + { + if (!toggle.Thrown) + { + toggle.Thrown = true; + + throw new InvalidOperationException("Simulated dispatch failure"); + } + + return next(ctx); + }, + "ThrowOnceDispatch"))); + } + + public sealed class ScheduledPing + { + public required string Text { get; init; } + } + + public sealed class ScheduledPingHandler(MessageRecorder recorder) : IEventHandler + { + public ValueTask HandleAsync(ScheduledPing message, CancellationToken cancellationToken) + { + recorder.Record(message); + + return default; + } + } + + private sealed class TestEnvironment(ServiceProvider provider, List hostedServices) + : IAsyncDisposable + { + public ServiceProvider Provider => provider; + + public async ValueTask DisposeAsync() + { + // Stop hosted services in reverse registration order, matching IHost shutdown, so the + // scheduled message worker stops before the messaging runtime it dispatches through. + for (var i = hostedServices.Count - 1; i >= 0; i--) + { + await hostedServices[i].StopAsync(default); + } + + await provider.DisposeAsync(); + } + } + + private sealed class DispatchFailureToggle + { + public bool Thrown; + } +} diff --git a/src/Mocha/test/Mocha.Transport.InMemory.Tests/Scheduling/InMemoryTransportScheduledMessageStoreTests.cs b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Scheduling/InMemoryTransportScheduledMessageStoreTests.cs new file mode 100644 index 00000000000..1e91f77f5f8 --- /dev/null +++ b/src/Mocha/test/Mocha.Transport.InMemory.Tests/Scheduling/InMemoryTransportScheduledMessageStoreTests.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Time.Testing; +using Mocha.Middlewares; +using Mocha.Transport.InMemory.Scheduling; + +namespace Mocha.Transport.InMemory.Tests.Scheduling; + +public class InMemoryTransportScheduledMessageStoreTests +{ + private static readonly DateTimeOffset s_now = new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Add_Should_ReturnPrefixedToken_When_MessagePersisted() + { + // arrange + using var store = CreateStore(); + + // act + var token = store.Add(CreateEnvelope("a"), s_now.AddMinutes(5)); + + // assert + Assert.StartsWith("in-memory-transport:", token); + } + + [Fact] + public void TryTakeDue_Should_ReturnFalse_When_EarliestNotDue() + { + // arrange + using var store = CreateStore(); + store.Add(CreateEnvelope("a"), s_now.AddMinutes(5)); + + // act + var taken = store.TryTakeDue(s_now, out var entry); + + // assert + Assert.False(taken); + Assert.Null(entry); + } + + [Fact] + public void TryTakeDue_Should_ReturnBothEntries_When_SameScheduledTime() + { + // arrange + // two entries share a due time; the (ScheduledTime, Id) comparer must keep both. + using var store = CreateStore(); + var due = s_now.AddMinutes(1); + store.Add(CreateEnvelope("a"), due); + store.Add(CreateEnvelope("b"), due); + + // act + var first = store.TryTakeDue(due, out _); + var second = store.TryTakeDue(due, out _); + var third = store.TryTakeDue(due, out _); + + // assert + Assert.True(first); + Assert.True(second); + Assert.False(third); + } + + [Fact] + public void NextDueTime_Should_ReturnEarliest_When_MultipleScheduled() + { + // arrange + using var store = CreateStore(); + store.Add(CreateEnvelope("late"), s_now.AddMinutes(10)); + store.Add(CreateEnvelope("early"), s_now.AddMinutes(2)); + + // act + var next = store.NextDueTime(); + + // assert + Assert.Equal(s_now.AddMinutes(2), next); + } + + [Fact] + public async Task CancelAsync_Should_RemoveEntry_When_ValidToken() + { + // arrange + using var store = CreateStore(); + var token = store.Add(CreateEnvelope("a"), s_now.AddMinutes(5)); + + // act + var cancelled = await store.CancelAsync(token, TestContext.Current.CancellationToken); + + // assert + Assert.True(cancelled); + Assert.Null(store.NextDueTime()); + } + + [Fact] + public async Task CancelAsync_Should_ReturnFalse_When_UnknownToken() + { + // arrange + using var store = CreateStore(); + + // act + var cancelled = await store.CancelAsync( + $"in-memory-transport:{Guid.NewGuid():D}", + TestContext.Current.CancellationToken); + + // assert + Assert.False(cancelled); + } + + [Fact] + public async Task CancelAsync_Should_ReturnFalse_When_MalformedToken() + { + // arrange + using var store = CreateStore(); + store.Add(CreateEnvelope("a"), s_now.AddMinutes(5)); + + // act + var wrongPrefix = await store.CancelAsync( + $"other-provider:{Guid.NewGuid():D}", + TestContext.Current.CancellationToken); + var unparsableId = await store.CancelAsync( + "in-memory-transport:not-a-guid", + TestContext.Current.CancellationToken); + + // assert + Assert.False(wrongPrefix); + Assert.False(unparsableId); + } + + [Fact] + public void Add_Should_CopyBody_When_OriginalMutatedAfterPersist() + { + // arrange + // the store must own its buffer; mutating the caller's array must not corrupt the entry. + using var store = CreateStore(); + var body = new byte[] { 1, 2, 3 }; + var envelope = new MessageEnvelope { MessageType = "T", Body = body }; + + // act + store.Add(envelope, s_now); + body[0] = 9; + var taken = store.TryTakeDue(s_now, out var entry); + + // assert + Assert.True(taken); + Assert.NotNull(entry); + Assert.Equal(new byte[] { 1, 2, 3 }, entry.Envelope.Body.ToArray()); + } + + private static InMemoryTransportScheduledMessageStore CreateStore() + => new(new FakeTimeProvider(s_now)); + + private static MessageEnvelope CreateEnvelope(string id) + => new() { MessageId = id, MessageType = "T", Body = new byte[] { 0 } }; +} diff --git a/website/content/docs/mocha/sagas.md b/website/content/docs/mocha/sagas.md index 499f44496d2..d083eafb461 100644 --- a/website/content/docs/mocha/sagas.md +++ b/website/content/docs/mocha/sagas.md @@ -623,7 +623,7 @@ A saga that waits for a message that never arrives will stay in its current stat Mocha provides a saga-level `Timeout()` API that sets a single deadline for the entire saga instance. The timeout is scheduled when the saga is created and automatically cancelled when the saga reaches any final state. -> **Prerequisites:** `Timeout()` schedules a `SagaTimedOutEvent` through the same scheduled message store used by `ScheduleSendAsync` - the PostgreSQL transport's own store, or the `UsePostgresScheduling()` fallback for transports without one (InMemory, RabbitMQ). If no store is available when the saga is created, the timeout is silently skipped: the saga proceeds without an automatic timeout and only receives a `SagaTimedOutEvent` if one is sent manually. See [Scheduling: Set up store-based scheduling](./scheduling.md#set-up-store-based-scheduling-for-rabbitmq) for setup instructions. +> **Prerequisites:** `Timeout()` schedules a `SagaTimedOutEvent` through the same scheduled message store used by `ScheduleSendAsync`: the PostgreSQL transport's own store, the InMemory transport's own store (registered automatically by `AddInMemory()`, non-durable), or the `UsePostgresScheduling()` fallback for RabbitMQ. If no store is available when the saga is created, the timeout is silently skipped: the saga proceeds without an automatic timeout and only receives a `SagaTimedOutEvent` if one is sent manually. See [Scheduling: Set up store-based scheduling](./scheduling.md#set-up-store-based-scheduling-for-rabbitmq) for setup instructions. ## Configure a saga-level timeout @@ -666,21 +666,21 @@ Use `OnTimeout()` on a state descriptor to define what happens when the timeout ## Key behaviors -| Behavior | Detail | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Scope | Per-saga instance, not per-state. The deadline covers the entire saga lifetime. | -| Duration | Fixed at configuration time via `TimeSpan`. | -| Auto-cancellation | When the saga reaches any final state (normal completion, error final state, etc.), the pending timeout is cancelled. | -| Late delivery | If the timeout fires after the saga was already deleted, the event is silently dropped. | -| Missing handler | If no `OnTimeout()` handler is configured for the current state, the saga throws an execution error. See [troubleshooting](#timeout-troubleshooting) below. | -| Recommended pattern | `DuringAny().OnTimeout()` handles the timeout regardless of which state the saga is in. | -| Response on timeout | Chain `.Respond()` on the timed-out final state to send a response back to the original requester. | -| Scheduling store | Requires a scheduled message store to schedule the timeout: the PostgreSQL transport's own store, or the `UsePostgresScheduling()` fallback for transports without one (InMemory, RabbitMQ). Without a store, the timeout is silently skipped rather than throwing - see [Scheduling](./scheduling.md#set-up-store-based-scheduling-for-rabbitmq) for setup. | +| Behavior | Detail | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Scope | Per-saga instance, not per-state. The deadline covers the entire saga lifetime. | +| Duration | Fixed at configuration time via `TimeSpan`. | +| Auto-cancellation | When the saga reaches any final state (normal completion, error final state, etc.), the pending timeout is cancelled. | +| Late delivery | If the timeout fires after the saga was already deleted, the event is silently dropped. | +| Missing handler | If no `OnTimeout()` handler is configured for the current state, the saga throws an execution error. See [troubleshooting](#timeout-troubleshooting) below. | +| Recommended pattern | `DuringAny().OnTimeout()` handles the timeout regardless of which state the saga is in. | +| Response on timeout | Chain `.Respond()` on the timed-out final state to send a response back to the original requester. | +| Scheduling store | Requires a scheduled message store to schedule the timeout: the PostgreSQL transport's own store, the InMemory transport's own store (non-durable), or the `UsePostgresScheduling()` fallback for RabbitMQ. Without a store, the timeout is silently skipped rather than throwing - see [Scheduling](./scheduling.md#set-up-store-based-scheduling-for-rabbitmq) for setup. | ## Timeout troubleshooting **Timeout never fires.** -Verify that a scheduled message store is configured for the saga's transport: either the PostgreSQL transport's own store (registered automatically by `AddPostgres()`) or the `UsePostgresScheduling()` fallback. Without one, the saga catches the resulting `NotSupportedException` and silently proceeds without scheduling a timeout - no error is raised, so a saga you expect to time out will simply never receive a `SagaTimedOutEvent`. +Verify that a scheduled message store is configured for the saga's transport: the PostgreSQL transport's own store (registered automatically by `AddPostgres()`), the InMemory transport's own store (registered automatically by `AddInMemory()`), or the `UsePostgresScheduling()` fallback for RabbitMQ. Without one, the saga catches the resulting `NotSupportedException` and silently proceeds without scheduling a timeout - no error is raised, so a saga you expect to time out will simply never receive a `SagaTimedOutEvent`. **"SagaExecutionException: No transition defined for SagaTimedOutEvent."** You configured `Timeout()` but did not add an `OnTimeout()` handler. Add a catch-all handler: diff --git a/website/content/docs/mocha/scheduling.md b/website/content/docs/mocha/scheduling.md index 787806867a9..7c1a0e46105 100644 --- a/website/content/docs/mocha/scheduling.md +++ b/website/content/docs/mocha/scheduling.md @@ -1,6 +1,6 @@ --- title: "Scheduling" -description: "Schedule messages for future delivery in Mocha using absolute times or relative delays, with durable Postgres or in-memory persistence." +description: "Schedule messages for future delivery in Mocha using absolute times or relative delays, with durable Postgres persistence or non-durable in-memory delivery." --- Sometimes a message should not be delivered right now. A welcome email goes out 30 minutes after signup. A payment retry fires 24 hours after the first failure. A saga timeout triggers if no response arrives within 5 minutes. Scheduling lets you hand a message to the bus with a future delivery time, and the infrastructure takes care of the rest. If plans change, you can cancel a scheduled message before it is dispatched. @@ -129,7 +129,7 @@ if (result.IsCancellable) | `ScheduledTime` | `DateTimeOffset` | The time at which the message is scheduled for delivery. | | `IsCancellable` | `bool` | `true` when the scheduling infrastructure supports cancellation and a token was assigned. | -The scheduling middleware is always part of the dispatch pipeline. When you set a `ScheduledTime`, it resolves a scheduled message store for the current transport - the transport's own store first (for example the PostgreSQL transport's native store), then the `UsePostgresScheduling()` fallback store if one is registered. `IsCancellable` is `true` whenever a store was found and persisted the message. If no store is available for the transport, scheduling does not silently fall back to native delivery - it throws `NotSupportedException`. +The scheduling middleware is always part of the dispatch pipeline. When you set a `ScheduledTime`, it resolves a scheduled message store for the current transport - the transport's own store first (for example the PostgreSQL or InMemory transport's native store), then the `UsePostgresScheduling()` fallback store if one is registered. `IsCancellable` is `true` whenever a store was found and persisted the message. If no store is available for the transport, scheduling does not silently fall back to native delivery - it throws `NotSupportedException`. ## Real-world example: cancellable reminder @@ -173,7 +173,7 @@ public class OrderService(IMessageBus bus, IOrderRepository orders) # Set up store-based scheduling for RabbitMQ -The PostgreSQL transport handles scheduling natively with no extra setup. InMemory and RabbitMQ have no scheduling store of their own, so a scheduled dispatch on either one throws `NotSupportedException` unless you configure a fallback: a Postgres-backed message store that persists scheduled messages and dispatches them through a background worker. +The PostgreSQL and InMemory transports handle scheduling natively with no extra setup. RabbitMQ has no scheduling store of its own, so a scheduled dispatch on it throws `NotSupportedException` unless you configure a fallback: a Postgres-backed message store that persists scheduled messages and dispatches them through a background worker. **1. Add the NuGet packages.** @@ -226,15 +226,15 @@ dotnet ef database update # Transport scheduling behavior -Only the PostgreSQL transport has a scheduling store of its own. InMemory and RabbitMQ need the `UsePostgresScheduling()` fallback; without it, a scheduled dispatch on either transport throws `NotSupportedException`. +The PostgreSQL and InMemory transports each have a scheduling store of their own. RabbitMQ needs the `UsePostgresScheduling()` fallback; without it, a scheduled dispatch on RabbitMQ throws `NotSupportedException`. -| Transport | Scheduling type | Durability | Cancellation support | Setup required | -| ---------- | ----------------------------------------- | ------------------------------- | ------------------------------------------ | ----------------------------------------- | -| InMemory | None - requires `UsePostgresScheduling()` | Durable, via the fallback store | Yes, via the fallback store | `UsePostgresScheduling()` + EF Core model | -| PostgreSQL | Native (transport-owned store) | Durable, survives restarts | Yes (token prefixed `postgres-transport:`) | None | -| RabbitMQ | None - requires `UsePostgresScheduling()` | Durable, via the fallback store | Yes, via the fallback store | `UsePostgresScheduling()` + EF Core model | +| Transport | Scheduling type | Durability | Cancellation support | Setup required | +| ---------- | ----------------------------------------- | ------------------------------- | ------------------------------------------- | ----------------------------------------- | +| InMemory | Native (transport-owned store) | Non-durable, lost on restart | Yes (token prefixed `in-memory-transport:`) | None | +| PostgreSQL | Native (transport-owned store) | Durable, survives restarts | Yes (token prefixed `postgres-transport:`) | None | +| RabbitMQ | None - requires `UsePostgresScheduling()` | Durable, via the fallback store | Yes, via the fallback store | `UsePostgresScheduling()` + EF Core model | -**InMemory:** The transport has no scheduling store of its own. Scheduling a message throws `NotSupportedException` unless `UsePostgresScheduling()` is registered as a fallback. With the fallback configured, scheduled messages are persisted to Postgres and dispatched through a background worker, the same as RabbitMQ below. +**InMemory:** The transport handles scheduling natively through its own scheduled message store, registered automatically when you call `AddInMemory()`. When you set `ScheduledTime`, a background worker holds the message in process memory and dispatches it once the scheduled time arrives. No additional setup is required. Cancellation is supported - the returned token is prefixed `in-memory-transport:` and can be passed to `CancelScheduledMessageAsync`. The store is not durable: it lives only in process memory, so any message still pending when the process stops is lost. **PostgreSQL:** The transport handles scheduling natively through its own scheduled message store, registered automatically when you call `AddPostgres()`. When you set `ScheduledTime`, the transport writes a `scheduled_time` column alongside the message instead of sending it immediately, and only delivers it to consumers after the scheduled time has passed. No additional setup is required beyond the standard [PostgreSQL transport configuration](./transports/postgres.md). Cancellation is supported - the returned token is prefixed `postgres-transport:` and can be passed to `CancelScheduledMessageAsync`. @@ -242,7 +242,9 @@ Only the PostgreSQL transport has a scheduling store of its own. InMemory and Ra ## Retry behavior -If a scheduled message fails to dispatch, the scheduler retries with exponential backoff. Each failed attempt increases the wait time before the next retry. After 10 attempts (the default `max_attempts`), the message is no longer eligible for dispatch. You can inspect failed messages by querying the `scheduled_messages` table and checking the `last_error` column. +This section applies to the Postgres-backed scheduled message store, used natively by the PostgreSQL transport or through the `UsePostgresScheduling()` fallback for RabbitMQ. If a scheduled message fails to dispatch, the scheduler retries with exponential backoff. Each failed attempt increases the wait time before the next retry. After 10 attempts (the default `max_attempts`), the message is no longer eligible for dispatch. You can inspect failed messages by querying the `scheduled_messages` table and checking the `last_error` column. + +The InMemory transport's store does not retry failed dispatches. If a scheduled message fails to dispatch, it is dropped and the failure is logged. ## Multiple service instances @@ -301,7 +303,7 @@ See [Sagas](./sagas.md) for the full saga configuration guide. # Troubleshooting **Scheduled messages are not being delivered.** -Check that the background worker is running. Look for `Scheduler sleeping until ...` log entries at `Information` level. If there are no log entries, verify that `UsePostgresScheduling()` is registered in your service configuration. This applies to InMemory and RabbitMQ - neither has a scheduling store of its own, so both need the fallback to deliver scheduled messages at all. +Check that the background worker is running. Look for `Scheduler sleeping until ...` log entries at `Information` level (emitted by the Postgres-backed worker; the in-memory worker does not log these). If there are no log entries for RabbitMQ, verify that `UsePostgresScheduling()` is registered in your service configuration - RabbitMQ has no scheduling store of its own, so it needs the fallback to deliver scheduled messages at all. `AddInMemory()` and `AddPostgres()` register their own stores and background workers automatically, so this step does not apply to those transports. **Messages are delivered immediately instead of at the scheduled time.** Messages scheduled for a time in the past are dispatched immediately. Verify that your `ScheduledTime` is in the future. @@ -309,8 +311,8 @@ Messages scheduled for a time in the past are dispatched immediately. Verify tha **"Could not deserialize message body" errors in logs.** The dispatcher could not parse the stored envelope. This can happen if the message type was renamed or removed after the message was scheduled. The dispatcher drops messages it cannot deserialize and logs at `Critical` level. -**Scheduled messages fail repeatedly.** -The dispatcher records each failure in the `last_error` column and retries with exponential backoff. After 10 attempts, the message is no longer eligible for dispatch. Query the `scheduled_messages` table and inspect the `last_error` column for diagnostics: +**Scheduled messages fail repeatedly (Postgres-backed scheduling).** +The Postgres-backed dispatcher records each failure in the `last_error` column and retries with exponential backoff. After 10 attempts, the message is no longer eligible for dispatch. Query the `scheduled_messages` table and inspect the `last_error` column for diagnostics: ```sql SELECT id, scheduled_time, times_sent, last_error @@ -318,14 +320,16 @@ FROM scheduled_messages WHERE times_sent >= max_attempts; ``` +The in-memory transport does not retry or record errors: a scheduled message whose dispatch fails is dropped, and the failure is logged. + **Multiple service instances dispatch the same message.** -This does not happen. The dispatcher uses row-level locking to ensure each message is processed by exactly one instance. +This does not happen with Postgres-backed scheduling: the dispatcher uses row-level locking to ensure each message is processed by exactly one instance. The in-memory transport is single-process, so this does not apply. **Cancellation returns false even though I have a valid token.** The message was already dispatched before the cancellation request reached the store. Once the background worker picks up a message and delivers it, the row is deleted and cancellation is no longer possible. If you need a wider cancellation window, schedule messages further in the future or check `SchedulingResult.IsCancellable` to confirm the infrastructure supports cancellation. **`SchedulePublishAsync`/`ScheduleSendAsync` throws `NotSupportedException` instead of returning a result.** -No scheduled message store is available for the current transport. `SchedulingResult.IsCancellable` is `true` whenever a call succeeds - there is no store that persists a message without returning a cancellable token. Configure `UsePostgresScheduling()` with an EF Core DbContext as a fallback, or use the PostgreSQL transport, which registers its own store automatically. +No scheduled message store is available for the current transport. `SchedulingResult.IsCancellable` is `true` whenever a call succeeds - there is no store that persists a message without returning a cancellable token. Configure `UsePostgresScheduling()` with an EF Core DbContext as a fallback for RabbitMQ, or use the PostgreSQL or InMemory transport, both of which register their own store automatically. # Next steps diff --git a/website/content/docs/mocha/transports/in-memory.md b/website/content/docs/mocha/transports/in-memory.md index 5767430bec0..6aa252ccc96 100644 --- a/website/content/docs/mocha/transports/in-memory.md +++ b/website/content/docs/mocha/transports/in-memory.md @@ -164,6 +164,25 @@ builder.Services // AuditHandler → InMemory (claimed) ``` +# Scheduled delivery + +`AddInMemory()` registers a scheduled message store and a background worker for the transport, so scheduling messages for future delivery works with no extra setup: + +```csharp +var result = await bus.SchedulePublishAsync( + new PaymentReminderEvent { OrderId = orderId }, + DateTimeOffset.UtcNow.AddHours(24), + cancellationToken); +``` + +The background worker holds scheduled messages in process memory and dispatches each one once its scheduled time arrives. Cancel a pending message with the token returned in `SchedulingResult`: + +```csharp +await bus.CancelScheduledMessageAsync(result.Token!, cancellationToken); +``` + +Scheduled messages live only in process memory. They are not persisted to disk, so any message still pending when the process stops is lost. See [Scheduling](../scheduling.md) for the full scheduling API and how it behaves across transports. + # Next steps - [RabbitMQ Transport](./rabbitmq.md) - Configure the RabbitMQ transport for production deployments. diff --git a/website/content/docs/mocha/transports/index.md b/website/content/docs/mocha/transports/index.md index 764555668f1..82000572cf9 100644 --- a/website/content/docs/mocha/transports/index.md +++ b/website/content/docs/mocha/transports/index.md @@ -49,15 +49,15 @@ Each `Add{Transport}()` method registers a transport instance, applies default c Use this decision matrix to pick the right transport. Each column includes trade-offs - choose the one whose trade-offs you can accept: -| Criterion | InMemory | PostgreSQL | RabbitMQ | -| ------------------ | ----------------------------------------- | ------------------------------------------- | ------------------------------------------- | -| Setup effort | None - zero dependencies | Requires a PostgreSQL database | Requires a running broker | -| Message durability | **Messages lost on process exit** | Messages are stored in database tables | Messages survive broker restarts | -| Multi-process | **Single process only** | Multiple services sharing the same database | Multiple services, multiple instances | -| Request/reply | Supported | Supported | Supported | -| Native scheduling | None - requires `UsePostgresScheduling()` | Yes, built-in and cancellable | None - requires `UsePostgresScheduling()` | -| Operational cost | None | Database capacity, migrations, monitoring | Broker infrastructure, monitoring, upgrades | -| Network latency | None - in-process | Database round trip | Broker round trip | +| Criterion | InMemory | PostgreSQL | RabbitMQ | +| ------------------ | ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | +| Setup effort | None - zero dependencies | Requires a PostgreSQL database | Requires a running broker | +| Message durability | **Messages lost on process exit** | Messages are stored in database tables | Messages survive broker restarts | +| Multi-process | **Single process only** | Multiple services sharing the same database | Multiple services, multiple instances | +| Request/reply | Supported | Supported | Supported | +| Native scheduling | Yes, built-in and cancellable (non-durable) | Yes, built-in and cancellable | None - requires `UsePostgresScheduling()` | +| Operational cost | None | Database capacity, migrations, monitoring | Broker infrastructure, monitoring, upgrades | +| Network latency | None - in-process | Database round trip | Broker round trip | **InMemory limitations:** Because all messages live in process memory, the InMemory transport cannot model multi-service fan-out, cannot survive process restarts, and does not exercise RabbitMQ-specific behavior like connection recovery, acknowledgement semantics, or topology conflicts.