Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
84f5909
Add in-memory transport scheduled message store
glen-84 Jul 16, 2026
c04e998
Add scheduled delivery to the in-memory transport
glen-84 Jul 16, 2026
a4639a1
Remove dead scheduled-store fallback from saga timeout test
glen-84 Jul 16, 2026
f41db74
[Website] Document in-memory transport scheduled delivery
glen-84 Jul 17, 2026
a0603d2
Propagate trace context in in-memory scheduled dispatch
glen-84 Jul 17, 2026
c480e61
Fix lost wake-up when scheduler signal is notified before wait
glen-84 Jul 17, 2026
f330638
Log non-shutdown cancellation in in-memory scheduled dispatch
glen-84 Jul 17, 2026
5759176
[Website] Scope Postgres-specific scheduling troubleshooting
glen-84 Jul 17, 2026
ff1b640
Keep Postgres scheduling tests on the EF fallback store
glen-84 Jul 17, 2026
4929a21
Fix scheduler signal missed wake from stale target between waits
glen-84 Jul 17, 2026
bf886db
Stop hosted services in reverse order in scheduling test teardown
glen-84 Jul 17, 2026
be6f4ab
Cover in-memory scheduler dispatch failure and malformed cancel token
glen-84 Jul 17, 2026
60536df
Merge branch 'main' into gai/inmemory-transport-scheduling-v2
glen-84 Jul 17, 2026
2870899
Add owned-body envelope copy helper and simplify in-memory DI
glen-84 Aug 13, 2026
a98bbbd
Merge branch 'main' into gai/inmemory-transport-scheduling-v2
glen-84 Aug 24, 2026
cb07fef
Merge branch 'main' into gai/inmemory-transport-scheduling-v2
glen-84 Oct 4, 2026
ad485c1
Merge branch 'main' into gai/inmemory-transport-scheduling-v2
PascalSenn Oct 6, 2026
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
@@ -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;

/// <summary>
/// Extension methods for registering the in-memory messaging transport on an <see cref="IMessageBusHostBuilder"/>.
Expand All @@ -19,6 +25,27 @@ public static IMessageBusHostBuilder AddInMemory(

busBuilder.ConfigureMessageBus(b => b.AddTransport(transport));

busBuilder.Services.TryAddSingleton(
static sp => new InMemoryTransportScheduledMessageStore(sp.GetTimeProvider()));

busBuilder.Services.AddSingleton(
new ScheduledMessageStoreRegistration(
transport,
InMemoryTransportScheduledMessageStore.TokenPrefix,
static sp => sp.GetRequiredService<InMemoryTransportScheduledMessageStore>()));

busBuilder.Services.TryAddSingleton(
static sp => new InMemoryScheduledMessageWorker(
sp,
sp.GetRequiredService<IMessagingRuntime>(),
sp.GetRequiredService<IMessagingPools>(),
sp.GetRequiredService<InMemoryTransportScheduledMessageStore>(),
sp.GetTimeProvider(),
sp.GetRequiredService<ILogger<InMemoryScheduledMessageWorker>>()));

busBuilder.Services.AddHostedService(
static sp => sp.GetRequiredService<InMemoryScheduledMessageWorker>());

return busBuilder;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="..\Mocha.Abstractions\Mocha.Abstractions.csproj" />
<ProjectReference Include="..\Mocha\Mocha.csproj" />
<ProjectReference Include="..\Mocha.Outbox\Mocha.Outbox.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
internal sealed class InMemoryScheduledMessageWorker(
IServiceProvider services,
IMessagingRuntime runtime,
IMessagingPools pools,
InMemoryTransportScheduledMessageStore store,
TimeProvider timeProvider,
ILogger<InMemoryScheduledMessageWorker> logger)
: IHostedService, IAsyncDisposable
{
private readonly ObjectPool<DispatchContext> _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);
}
Loading
Loading