From 173b9308fdac97a4af6607f05643a9cb7496d141 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 25 Mar 2026 19:30:18 -0500 Subject: [PATCH 01/27] Experimenting with queue support --- Foundatio.Mediator.slnx | 1 + .../src/Common.Module/EntityAction.cs | 2 +- samples/ConsoleSample/ConsoleSample.csproj | 2 +- .../ConsoleSample/Handlers/QueueHandler.cs | 38 +++++++ samples/ConsoleSample/Messages/Messages.cs | 3 + samples/ConsoleSample/Program.cs | 8 +- samples/ConsoleSample/SampleRunner.cs | 22 +++- samples/ConsoleSample/ServiceConfiguration.cs | 4 + src/Foundatio.Mediator.Queues/AssemblyInfo.cs | 3 + .../Foundatio.Mediator.Queues.csproj | 18 ++++ .../MediatorConsumer.cs | 29 +++++ .../QueueAttribute.cs | 68 ++++++++++++ .../QueueMiddleware.cs | 68 ++++++++++++ .../QueueServiceExtensions.cs | 102 ++++++++++++++++++ 14 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 samples/ConsoleSample/Handlers/QueueHandler.cs create mode 100644 src/Foundatio.Mediator.Queues/AssemblyInfo.cs create mode 100644 src/Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj create mode 100644 src/Foundatio.Mediator.Queues/MediatorConsumer.cs create mode 100644 src/Foundatio.Mediator.Queues/QueueAttribute.cs create mode 100644 src/Foundatio.Mediator.Queues/QueueMiddleware.cs create mode 100644 src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs diff --git a/Foundatio.Mediator.slnx b/Foundatio.Mediator.slnx index 55212642..260d4496 100644 --- a/Foundatio.Mediator.slnx +++ b/Foundatio.Mediator.slnx @@ -21,6 +21,7 @@ + diff --git a/samples/CleanArchitectureSample/src/Common.Module/EntityAction.cs b/samples/CleanArchitectureSample/src/Common.Module/EntityAction.cs index bc9f2fc4..ab93aeec 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/EntityAction.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/EntityAction.cs @@ -1,4 +1,4 @@ -ο»Ώnamespace Common.Module; +namespace Common.Module; public class EntityAction { diff --git a/samples/ConsoleSample/ConsoleSample.csproj b/samples/ConsoleSample/ConsoleSample.csproj index caf63652..799c28f3 100644 --- a/samples/ConsoleSample/ConsoleSample.csproj +++ b/samples/ConsoleSample/ConsoleSample.csproj @@ -25,7 +25,7 @@ - + diff --git a/samples/ConsoleSample/Handlers/QueueHandler.cs b/samples/ConsoleSample/Handlers/QueueHandler.cs new file mode 100644 index 00000000..3ea01665 --- /dev/null +++ b/samples/ConsoleSample/Handlers/QueueHandler.cs @@ -0,0 +1,38 @@ +using ConsoleSample.Messages; +using Foundatio.Mediator.Queues; +using Microsoft.Extensions.Logging; + +namespace ConsoleSample.Handlers; + +[Queue(Concurrency = 2)] +public class ReportHandler +{ + private readonly ILogger _logger; + + public ReportHandler(ILogger logger) + { + _logger = logger; + } + + public async Task HandleAsync(GenerateReport message, CancellationToken ct) + { + _logger.LogInformation("πŸ“Š Starting report generation: {ReportName} ({ItemCount} items)", + message.ReportName, message.ItemCount); + + Console.WriteLine($"πŸ“Š [Queue Worker] Generating report: {message.ReportName}"); + + for (int i = 1; i <= message.ItemCount; i++) + { + ct.ThrowIfCancellationRequested(); + + int progress = (int)((double)i / message.ItemCount * 100); + + // Simulate work + await Task.Delay(200, ct); + + Console.WriteLine($"πŸ“Š [Queue Worker] Item {i}/{message.ItemCount} processed ({progress}%)"); + } + + Console.WriteLine($"πŸ“Š [Queue Worker] Report '{message.ReportName}' completed successfully!"); + } +} diff --git a/samples/ConsoleSample/Messages/Messages.cs b/samples/ConsoleSample/Messages/Messages.cs index af686cb9..535a4c23 100644 --- a/samples/ConsoleSample/Messages/Messages.cs +++ b/samples/ConsoleSample/Messages/Messages.cs @@ -35,4 +35,7 @@ public record Order(string Id, string CustomerId, decimal Amount, string Descrip // Counter stream request public record CounterStreamRequest { } +// Queue messages +public record GenerateReport(string ReportName, int ItemCount); + public interface IValidatable { } diff --git a/samples/ConsoleSample/Program.cs b/samples/ConsoleSample/Program.cs index 7f0f6f1f..5cd3b3b7 100644 --- a/samples/ConsoleSample/Program.cs +++ b/samples/ConsoleSample/Program.cs @@ -11,8 +11,14 @@ var host = builder.Build(); +// Start the host so background services (queue workers) run +await host.StartAsync(); + // Get mediator and run samples var mediator = host.Services.GetRequiredService(); -var sampleRunner = new SampleRunner(mediator, host.Services); +var sampleRunner = new SampleRunner(mediator); await sampleRunner.RunAllSamplesAsync(); + +// Stop the host gracefully +await host.StopAsync(); diff --git a/samples/ConsoleSample/SampleRunner.cs b/samples/ConsoleSample/SampleRunner.cs index 064263ce..b44fe699 100644 --- a/samples/ConsoleSample/SampleRunner.cs +++ b/samples/ConsoleSample/SampleRunner.cs @@ -7,7 +7,7 @@ public class SampleRunner { private readonly IMediator _mediator; - public SampleRunner(IMediator mediator, IServiceProvider serviceProvider) + public SampleRunner(IMediator mediator) { _mediator = mediator; } @@ -21,6 +21,7 @@ public async Task RunAllSamplesAsync() await RunOrderCrudExamples(); await RunCounterStreamExample(); await RunEventPublishingExamples(); + await RunQueueExample(); Console.WriteLine("\nπŸŽ‰ All samples completed successfully!"); } @@ -128,4 +129,23 @@ private async Task RunEventPublishingExamples() Console.WriteLine(); } + + private async Task RunQueueExample() + { + Console.WriteLine("5️⃣ Queue Processing (via SlimMessageBus)"); + Console.WriteLine("==========================================\n"); + + + Console.WriteLine("πŸ“¨ Enqueuing report generation (will be processed asynchronously)...\n"); + + // This returns immediately β€” the message is published to the bus + await _mediator.InvokeAsync(new GenerateReport("Monthly Sales Report", 5)); + + // Wait for the bus consumer to process the message + Console.WriteLine("⏳ Waiting for consumer to process...\n"); + await Task.Delay(2000); + + Console.WriteLine("βœ… Queue processing completed"); + Console.WriteLine(); + } } diff --git a/samples/ConsoleSample/ServiceConfiguration.cs b/samples/ConsoleSample/ServiceConfiguration.cs index 540cc8d9..d8f83cd6 100644 --- a/samples/ConsoleSample/ServiceConfiguration.cs +++ b/samples/ConsoleSample/ServiceConfiguration.cs @@ -1,4 +1,5 @@ using Foundatio.Mediator; +using Foundatio.Mediator.Queues; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -14,6 +15,9 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi // Add Foundatio Mediator services.AddMediator(); + // Add queue support (discovers [Queue]-decorated handlers and starts background workers) + services.AddMediatorQueues(); + return services; } } diff --git a/src/Foundatio.Mediator.Queues/AssemblyInfo.cs b/src/Foundatio.Mediator.Queues/AssemblyInfo.cs new file mode 100644 index 00000000..cde18f69 --- /dev/null +++ b/src/Foundatio.Mediator.Queues/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Foundatio.Mediator; + +[assembly: FoundatioModule] diff --git a/src/Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj b/src/Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj new file mode 100644 index 00000000..41fe3929 --- /dev/null +++ b/src/Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj @@ -0,0 +1,18 @@ + + + + + net10.0 + latest + enable + Foundatio.Mediator.Queues + + + + + + + + + + diff --git a/src/Foundatio.Mediator.Queues/MediatorConsumer.cs b/src/Foundatio.Mediator.Queues/MediatorConsumer.cs new file mode 100644 index 00000000..f3b056ee --- /dev/null +++ b/src/Foundatio.Mediator.Queues/MediatorConsumer.cs @@ -0,0 +1,29 @@ +using SlimMessageBus; + +namespace Foundatio.Mediator.Queues; + +/// +/// Generic SlimMessageBus consumer that bridges bus messages back through the mediator pipeline. +/// Sets so the middleware passes through +/// to next() instead of re-enqueuing, allowing the full middleware pipeline +/// (logging, validation, auth, etc.) to execute before the handler runs. +/// +public class MediatorConsumer : IConsumer where T : class +{ + private readonly IMediator _mediator; + + public MediatorConsumer(IMediator mediator) => _mediator = mediator; + + public async Task OnHandle(T message, CancellationToken cancellationToken) + { + QueueMiddleware.IsProcessing = true; + try + { + await _mediator.InvokeAsync(message, cancellationToken).ConfigureAwait(false); + } + finally + { + QueueMiddleware.IsProcessing = false; + } + } +} diff --git a/src/Foundatio.Mediator.Queues/QueueAttribute.cs b/src/Foundatio.Mediator.Queues/QueueAttribute.cs new file mode 100644 index 00000000..4588b3de --- /dev/null +++ b/src/Foundatio.Mediator.Queues/QueueAttribute.cs @@ -0,0 +1,68 @@ +using Foundatio.Mediator; + +namespace Foundatio.Mediator.Queues; + +/// +/// Marks a handler class or method for queue-based processing. +/// When applied, invocations via mediator.InvokeAsync() will publish the message +/// to a message bus for asynchronous processing instead of executing the handler inline. +/// +/// +/// +/// The handler is processed by a that receives messages +/// from SlimMessageBus and dispatches them back through the mediator pipeline (including +/// all middleware except re-enqueuing). +/// +/// +/// Queue infrastructure is backed by SlimMessageBus, which supports in-memory, Kafka, +/// RabbitMQ, Azure Service Bus, and many other transports. +/// +/// +/// +/// +/// [Queue(Concurrency = 3)] +/// public class OrderProcessingHandler +/// { +/// public async Task<Result> HandleAsync( +/// ProcessOrder message, +/// CancellationToken ct) +/// { +/// // ... do work ... +/// return Result.Success(); +/// } +/// } +/// +/// +[UseMiddleware(typeof(QueueMiddleware))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class QueueAttribute : Attribute +{ + /// + /// Override the queue name. Defaults to the message type name. + /// + public string? QueueName { get; set; } + + /// + /// Maximum number of retry attempts before dead-lettering. Default is 2 (Foundatio default). + /// Total attempts = Retries + 1 (initial attempt + retries). + /// + public int Retries { get; set; } = 2; + + /// + /// Work item timeout as a TimeSpan string (e.g., "00:05:00"). + /// If a message is not completed within this duration, it is automatically abandoned. + /// Default is 5 minutes. + /// + public string? Timeout { get; set; } + + /// + /// Number of concurrent workers processing this queue. Default is 1. + /// + public int Concurrency { get; set; } = 1; + + /// + /// When true, the worker automatically completes the message on success + /// and abandons it on exception. Default is true. + /// + public bool AutoComplete { get; set; } = true; +} diff --git a/src/Foundatio.Mediator.Queues/QueueMiddleware.cs b/src/Foundatio.Mediator.Queues/QueueMiddleware.cs new file mode 100644 index 00000000..b3e5fa78 --- /dev/null +++ b/src/Foundatio.Mediator.Queues/QueueMiddleware.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; +using System.Reflection; +using SlimMessageBus; + +namespace Foundatio.Mediator.Queues; + +/// +/// Middleware that intercepts handler invocations for -decorated handlers. +/// +/// +/// +/// On the enqueue path (normal caller), this middleware publishes the message to +/// SlimMessageBus and returns immediately β€” no other middleware runs. +/// +/// +/// On the process path (when calls back through +/// the mediator), this middleware passes through to next() so the full pipeline +/// (logging, validation, auth, etc.) executes before the handler. +/// +/// +/// Order is set low so this middleware runs as the outermost ExecuteAsync wrapper, +/// ensuring fast enqueue with minimal overhead. +/// +/// +[Middleware(Order = -100, ExplicitOnly = true)] +public class QueueMiddleware +{ + private static readonly AsyncLocal s_isProcessing = new(); + private static readonly ConcurrentDictionary s_publishMethods = new(); + + private static readonly MethodInfo s_publishTypedMethod = typeof(QueueMiddleware) + .GetMethod(nameof(PublishTypedAsync), BindingFlags.NonPublic | BindingFlags.Static)!; + + /// + /// Indicates the current async context is processing a message from the bus. + /// Set by to prevent re-enqueuing. + /// + internal static bool IsProcessing + { + get => s_isProcessing.Value; + set => s_isProcessing.Value = value; + } + + private readonly IMessageBus _bus; + + public QueueMiddleware(IMessageBus bus) => _bus = bus; + + public async ValueTask ExecuteAsync( + object message, + HandlerExecutionDelegate next, + HandlerExecutionInfo handlerInfo) + { + // Process path: consumer is calling back through the mediator β€” run the full pipeline + if (IsProcessing) + return await next().ConfigureAwait(false); + + // Enqueue path: publish to the bus and return immediately + var method = s_publishMethods.GetOrAdd(message.GetType(), + type => s_publishTypedMethod.MakeGenericMethod(type)); + + await ((Task)method.Invoke(null, [_bus, message])!).ConfigureAwait(false); + + return Result.Accepted("Message queued"); + } + + private static Task PublishTypedAsync(IMessageBus bus, T message) where T : class + => bus.Publish(message); +} diff --git a/src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs b/src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs new file mode 100644 index 00000000..a9396b85 --- /dev/null +++ b/src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs @@ -0,0 +1,102 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SlimMessageBus.Host; +using SlimMessageBus.Host.Memory; + +namespace Foundatio.Mediator.Queues; + +/// +/// Extension methods for registering SlimMessageBus-backed queue support for Foundatio.Mediator. +/// +public static class QueueServiceExtensions +{ + private static readonly MethodInfo s_configureMethod = typeof(QueueServiceExtensions) + .GetMethod(nameof(ConfigureQueueForType), BindingFlags.NonPublic | BindingFlags.Static)!; + + /// + /// Adds queue processing support to Foundatio.Mediator using SlimMessageBus. + /// Handlers decorated with will have their messages + /// enqueued via the message bus for asynchronous processing. + /// + /// The service collection. + /// + /// Optional configuration callback for the . + /// Use this to set a transport provider (e.g. Kafka, RabbitMQ, Azure Service Bus). + /// If not provided, defaults to the in-memory transport. + /// + /// The service collection for chaining. + /// + /// + /// services.AddMediator(); + /// services.AddMediatorQueues(); + /// + /// // Or with a real transport: + /// services.AddMediatorQueues(mbb => mbb.WithProviderServiceBus(cfg => { ... })); + /// + /// + public static IServiceCollection AddMediatorQueues( + this IServiceCollection services, + Action? configureBus = null) + { + // Prevent double registration + if (services.Any(sd => sd.ServiceType == typeof(QueueMiddleware))) + return services; + + var registry = services.GetHandlerRegistry() + ?? throw new InvalidOperationException( + "AddMediatorQueues requires AddMediator to be called first."); + + var queueHandlers = registry.GetHandlersWithAttribute(); + if (queueHandlers.Count == 0) + return services; + + // Register the middleware and generic consumer + services.AddTransient(); + services.AddTransient(typeof(MediatorConsumer<>)); + + services.AddSlimMessageBus(mbb => + { + // Register produce/consume for each [Queue]-decorated handler + foreach (var handler in queueHandlers) + { + var messageType = handler.MessageType; + if (messageType == null) + continue; + + var queueAttr = handler.GetPreferredAttribute()?.Attribute as QueueAttribute; + var queueName = !string.IsNullOrWhiteSpace(queueAttr?.QueueName) + ? queueAttr!.QueueName! + : messageType.Name; + var concurrency = queueAttr?.Concurrency ?? 1; + + s_configureMethod.MakeGenericMethod(messageType) + .Invoke(null, [mbb, queueName, concurrency]); + } + + // Let the caller configure transport, serializer, etc. + if (configureBus != null) + configureBus(mbb); + else + mbb.WithProviderMemory(); // Default to in-memory + }); + + return services; + } + + /// + /// Strongly-typed helper invoked via reflection to register produce/consume + /// for a specific message type with the . + /// + private static void ConfigureQueueForType( + MessageBusBuilder mbb, string queueName, int concurrency) where T : class + { + mbb.Produce(x => x.DefaultTopic(queueName)); + mbb.Consume(x => + { + x.Topic(queueName); + x.WithConsumer>(); + x.Instances(concurrency); + }); + } +} From d12ab4ca6a0327dde82675fa800bab934957e66c Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 29 Mar 2026 13:56:15 -0500 Subject: [PATCH 02/27] Update queue implementation using new features --- ....Benchmarks.FoundatioBenchmarks-report.csv | 16 +-- docs/guide/performance.md | 14 +-- .../HandlerRegistration.cs | 6 +- .../HandlerRegistry.cs | 109 ++---------------- .../MediatorConsumer.cs | 42 +++++-- src/Foundatio.Mediator.Queues/QueueContext.cs | 74 ++++++++++++ .../QueueMiddleware.cs | 23 ++-- .../FoundatioModuleGenerator.cs | 7 +- src/Foundatio.Mediator/HandlerGenerator.cs | 24 +--- ...DefaultStaticHandler_WithOTel.verified.txt | 12 +- ...ationTests.EndpointGeneration.verified.txt | 10 +- ...HandlerWithMiddlewarePipeline.verified.txt | 6 +- ...onTests.InterceptorGeneration.verified.txt | 6 +- ...reWithBeforeStateButNoFinally.verified.txt | 12 +- ...nTests.ScopedDIHandler_NoOTel.verified.txt | 4 +- 15 files changed, 171 insertions(+), 194 deletions(-) create mode 100644 src/Foundatio.Mediator.Queues/QueueContext.cs diff --git a/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv b/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv index 6d5d0692..fe44cb53 100644 --- a/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv +++ b/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv @@ -1,8 +1,8 @@ -Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Allocated -Command,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,0.0225 ns,0.0089 ns,0.0084 ns,0.0000,0 B -Query,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,21.1025 ns,0.0860 ns,0.0718 ns,0.0010,48 B -Publish,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,13.4689 ns,0.1782 ns,0.1667 ns,0.0000,0 B -FullQuery,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,70.9998 ns,0.2518 ns,0.2355 ns,0.0017,88 B -CascadingMessages,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,44.1354 ns,0.2387 ns,0.2233 ns,0.0014,72 B -ShortCircuit,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,4.9373 ns,0.0502 ns,0.0470 ns,0.0000,0 B -ExecuteMiddleware,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,34.5473 ns,0.1470 ns,0.1303 ns,0.0032,160 B +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Median,Gen0,Allocated +Command,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,0.0131 ns,0.0076 ns,0.0067 ns,0.0103 ns,0.0000,0 B +Query,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,22.2566 ns,0.1766 ns,0.1474 ns,22.2179 ns,0.0094,48 B +Publish,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,19.1801 ns,0.1982 ns,0.1854 ns,19.1668 ns,0.0000,0 B +FullQuery,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,80.3133 ns,0.6438 ns,0.5707 ns,80.1793 ns,0.0172,88 B +CascadingMessages,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,46.1951 ns,0.2226 ns,0.1973 ns,46.2104 ns,0.0141,72 B +ShortCircuit,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,4.8048 ns,0.0512 ns,0.0479 ns,4.7965 ns,0.0000,0 B +ExecuteMiddleware,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,37.7127 ns,0.4727 ns,0.4191 ns,37.8504 ns,0.0054,160 B diff --git a/docs/guide/performance.md b/docs/guide/performance.md index 22f055a0..9e2a1309 100644 --- a/docs/guide/performance.md +++ b/docs/guide/performance.md @@ -4,7 +4,7 @@ Foundatio.Mediator aims to get as close to direct method call performance as pos ## Benchmark Results -> πŸ“Š **Last Updated:** 2026-02-23 +> πŸ“Š **Last Updated:** 2026-03-29 ### Commands @@ -15,7 +15,7 @@ Process a message with no return value. MethodMeanAllocated Direct_Command0.0000 ns0 B -Foundatio_Command0.0584 ns0 B +Foundatio_Command0.0131 ns0 B MediatorNet_Command8.4553 ns0 B ImmediateHandlers_Command11.0105 ns0 B MediatR_Command32.3613 ns128 B @@ -33,7 +33,7 @@ Request/response dispatch returning an Order object. MethodMeanAllocated Direct_Query21.1054 ns48 B -Foundatio_Query22.7625 ns48 B +Foundatio_Query22.2566 ns48 B MediatorNet_Query25.0262 ns48 B ImmediateHandlers_Query29.5762 ns48 B MediatR_Query53.4603 ns248 B @@ -53,7 +53,7 @@ Notification dispatched to 2 handlers. Direct_Publish0.0052 ns0 B MediatorNet_Publish5.6175 ns0 B -Foundatio_Publish16.2971 ns0 B +Foundatio_Publish19.1801 ns0 B ImmediateHandlers_Publish51.8625 ns32 B MediatR_Publish52.5791 ns440 B Wolverine_Publish1,755.3777 ns2,840 B @@ -72,7 +72,7 @@ Query where handler has an injected service (IOrderService) and timing middlewar Direct_FullQuery62.9251 ns160 B MediatorNet_FullQuery73.6510 ns88 B ImmediateHandlers_FullQuery74.1282 ns88 B -Foundatio_FullQuery77.3365 ns88 B +Foundatio_FullQuery80.3133 ns88 B Wolverine_FullQuery284.2368 ns944 B MassTransit_FullQuery5,914.7751 ns13,144 B @@ -88,7 +88,7 @@ CreateOrder returns an Order and publishes OrderCreatedEvent to 2 handlers. Foun Direct_CascadingMessages26.9792 ns144 B MediatorNet_CascadingMessages36.7020 ns72 B -Foundatio_CascadingMessages53.6296 ns72 B +Foundatio_CascadingMessages46.1951 ns72 B ImmediateHandlers_CascadingMessages82.9136 ns104 B MediatR_CascadingMessages113.6711 ns744 B Wolverine_CascadingMessages2,220.3872 ns4,056 B @@ -105,7 +105,7 @@ Middleware returns cached result; handler is never invoked. Each library uses it MethodMeanAllocated Direct_ShortCircuit0.2052 ns0 B -Foundatio_ShortCircuit5.1399 ns0 B +Foundatio_ShortCircuit4.8048 ns0 B MediatorNet_ShortCircuit8.2942 ns0 B ImmediateHandlers_ShortCircuit9.0116 ns0 B MediatR_ShortCircuit48.3730 ns416 B diff --git a/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs b/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs index cf65c553..fe97786b 100644 --- a/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs +++ b/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs @@ -68,7 +68,7 @@ private static PublishAsyncDelegate CreatePublishDelegate(HandleAsyncDelegate ha { return (mediator, msg, cancellationToken) => { - var task = handleAsync(mediator, msg, null, cancellationToken, null, skipAuthorization: true); + var task = handleAsync(mediator, msg, null, cancellationToken, null); if (task.IsCompletedSuccessfully) return default; return AwaitAndDiscard(task); @@ -289,10 +289,10 @@ public IReadOnlyList GetAttributes() where /// Delegate type for asynchronous handler dispatch. Used by source-generated handler wrappers. /// [EditorBrowsable(EditorBrowsableState.Never)] -public delegate ValueTask HandleAsyncDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType, bool skipAuthorization = false); +public delegate ValueTask HandleAsyncDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType); /// /// Delegate type for synchronous handler dispatch. Used by source-generated handler wrappers. /// [EditorBrowsable(EditorBrowsableState.Never)] -public delegate object? HandleDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType, bool skipAuthorization = false); +public delegate object? HandleDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType); diff --git a/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs b/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs index 1762810c..0ada014c 100644 --- a/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs +++ b/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs @@ -34,45 +34,6 @@ public sealed class HandlerRegistry : IDisposable private readonly ConcurrentDictionary _messageTypeMatchCache = new(); private readonly object _subscriptionWriteLock = new(); private volatile bool _disposed; - private volatile bool _startupLogged; - - /// - /// Gets or sets whether to log all registered handlers at startup. - /// Set during AddMediator; consumed on first call. - /// - internal bool LogHandlersAtStartup { get; set; } - - /// - /// Gets or sets whether to log the middleware pipeline at startup. - /// Set during AddMediator; consumed on first call. - /// - internal bool LogMiddlewareAtStartup { get; set; } - - /// - /// Logs startup information (handler/middleware registrations) using the provided logger. - /// Called once from the constructor so logging goes through MS logging. - /// Short-circuits on a volatile bool read, so subsequent calls are essentially free. - /// - internal void TryLogStartupInfo(IServiceProvider serviceProvider) - { - if (_startupLogged) - return; - _startupLogged = true; - - var loggerFactory = serviceProvider.GetService(typeof(ILoggerFactory)) as ILoggerFactory; - var logger = loggerFactory?.CreateLogger("Foundatio.Mediator"); - if (logger == null) - return; - - if (LogHandlersAtStartup) - ShowRegisteredHandlers(logger); - - if (LogMiddlewareAtStartup) - ShowRegisteredMiddleware(logger); - - if (!LogHandlersAtStartup && !LogMiddlewareAtStartup) - logger.LogInformation("Foundatio.Mediator registered {HandlerCount} handler(s) and {MiddlewareCount} middleware.", _allRegistrations.Count, _allMiddleware.Count); - } /// /// Adds a handler registration to the registry. Must be called before . @@ -497,9 +458,9 @@ private PublishAsyncDelegate[] BuildAndCachePublishHandlers(Type messageType) if (asyncMethod == null) return null; - HandleAsyncDelegate asyncDelegate = (mediator, message, callContext, ct, returnType, skipAuthorization) => + HandleAsyncDelegate asyncDelegate = (mediator, message, callContext, ct, returnType) => { - object? taskObj = asyncMethod.Invoke(null, [mediator, message, callContext, ct, returnType, skipAuthorization]); + object? taskObj = asyncMethod.Invoke(null, [mediator, message, callContext, ct, returnType]); return taskObj is ValueTask vt ? vt : (ValueTask)taskObj!; }; @@ -509,7 +470,7 @@ private PublishAsyncDelegate[] BuildAndCachePublishHandlers(Type messageType) var syncMethod = wrapperClosed.GetMethod("UntypedHandle", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (syncMethod != null) { - syncDelegate = (mediator, message, callContext, ct, returnType, skipAuthorization) => syncMethod.Invoke(null, [mediator, message, callContext, ct, returnType, skipAuthorization]); + syncDelegate = (mediator, message, callContext, ct, returnType) => syncMethod.Invoke(null, [mediator, message, callContext, ct, returnType]); } } @@ -543,7 +504,6 @@ public bool HasSubscribers /// /// /// The notification type to subscribe to. Can be a concrete type, base class, or interface. - /// Use to also receive publisher metadata. /// Messages are matched using . /// /// Token that ends the subscription when cancelled. @@ -564,10 +524,11 @@ public async IAsyncEnumerable SubscribeAsync( SingleReader = true }); - // Detect if T is MessageContext β€” if so, subscribe to TInner but wrap into the context. - var (subscriptionType, entry) = CreateSubscriptionEntry(channel.Writer); + var entry = new SubscriptionEntry( + msg => channel.Writer.TryWrite((T)msg), + () => channel.Writer.TryComplete()); - AddSubscription(subscriptionType, entry); + AddSubscription(typeof(T), entry); try { // Use WaitToReadAsync + TryRead instead of ReadAllAsync so we can @@ -596,57 +557,11 @@ public async IAsyncEnumerable SubscribeAsync( } finally { - RemoveSubscription(subscriptionType, entry); + RemoveSubscription(typeof(T), entry); channel.Writer.TryComplete(); } } - /// - /// Creates a appropriate for the channel type. - /// When is , the entry - /// subscribes to the inner message type and wraps writes with context. - /// Otherwise, it subscribes to directly and discards context. - /// - private static (Type subscriptionType, SubscriptionEntry entry) CreateSubscriptionEntry(ChannelWriter writer) - { - // Check if T is MessageContext - if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(MessageContext<>)) - { - // Use a generic helper to avoid Activator.CreateInstance on every write - var helperType = typeof(MessageContextSubscriptionHelper<>).MakeGenericType(typeof(T).GetGenericArguments()[0]); - var helper = (ISubscriptionEntryFactory)Activator.CreateInstance(helperType)!; - return helper.Create(writer); - } - else - { - var entry = new SubscriptionEntry( - (msg, _) => writer.TryWrite((T)msg), - () => writer.TryComplete()); - return (typeof(T), entry); - } - } - - private interface ISubscriptionEntryFactory - { - (Type subscriptionType, SubscriptionEntry entry) Create(ChannelWriter writer); - } - - /// - /// Generic helper that creates a for - /// subscriptions. Created once per subscription via reflection; the write delegate itself is a - /// direct generic call with no reflection per message. - /// - private sealed class MessageContextSubscriptionHelper : ISubscriptionEntryFactory> - { - public (Type subscriptionType, SubscriptionEntry entry) Create(ChannelWriter> writer) - { - var entry = new SubscriptionEntry( - (msg, ctx) => writer.TryWrite(new MessageContext((TInner)msg, ctx)), - () => writer.TryComplete()); - return (typeof(TInner), entry); - } - } - /// /// Fans out a published message to all active dynamic subscribers whose type filter matches. /// Non-blocking: never awaits. @@ -660,8 +575,6 @@ public void TryWriteSubscription(object message) if (groups.Count == 0) return; - // Capture the current activity context so it travels with the message through the channel. - var context = Activity.Current?.Context ?? default; var messageType = message.GetType(); // One-time IsAssignableFrom check per unique message type; cached thereafter. @@ -682,7 +595,7 @@ public void TryWriteSubscription(object message) if (groups.TryGetValue(matchingTypes[i], out var entries)) { for (int j = 0; j < entries.Length; j++) - entries[j].Write(message, context); + entries[j].Write(message); } } } @@ -744,9 +657,9 @@ private void RemoveSubscription(Type subscriptionType, SubscriptionEntry entry) } } - private sealed class SubscriptionEntry(Action write, Action complete) + private sealed class SubscriptionEntry(Action write, Action complete) { - public void Write(object message, ActivityContext context) => write(message, context); + public void Write(object message) => write(message); public void Complete() => complete(); } diff --git a/src/Foundatio.Mediator.Queues/MediatorConsumer.cs b/src/Foundatio.Mediator.Queues/MediatorConsumer.cs index f3b056ee..5665fea8 100644 --- a/src/Foundatio.Mediator.Queues/MediatorConsumer.cs +++ b/src/Foundatio.Mediator.Queues/MediatorConsumer.cs @@ -4,26 +4,44 @@ namespace Foundatio.Mediator.Queues; /// /// Generic SlimMessageBus consumer that bridges bus messages back through the mediator pipeline. -/// Sets so the middleware passes through -/// to next() instead of re-enqueuing, allowing the full middleware pipeline -/// (logging, validation, auth, etc.) to execute before the handler runs. +/// Calls the handler's directly with a +/// containing a , so the passes through +/// to next() instead of re-enqueuing, and so that handler methods can inject +/// for progress reporting and timeout renewal. /// public class MediatorConsumer : IConsumer where T : class { private readonly IMediator _mediator; + private readonly HandlerRegistration _registration; + private readonly string _queueName; - public MediatorConsumer(IMediator mediator) => _mediator = mediator; + public MediatorConsumer(IMediator mediator, HandlerRegistry registry) + { + _mediator = mediator; + + var registrations = registry.GetRegistrationsForMessageType(typeof(T)); + _registration = registrations.Count switch + { + 0 => throw new InvalidOperationException($"No handler registration found for message type {typeof(T).Name}"), + 1 => registrations[0], + _ => throw new InvalidOperationException($"Multiple handler registrations found for message type {typeof(T).Name}. Queue messages must have exactly one handler.") + }; + + var queueAttr = _registration.GetPreferredAttribute()?.Attribute as QueueAttribute; + _queueName = !string.IsNullOrWhiteSpace(queueAttr?.QueueName) + ? queueAttr!.QueueName! + : typeof(T).Name; + } public async Task OnHandle(T message, CancellationToken cancellationToken) { - QueueMiddleware.IsProcessing = true; - try - { - await _mediator.InvokeAsync(message, cancellationToken).ConfigureAwait(false); - } - finally + var queueContext = new QueueContext { - QueueMiddleware.IsProcessing = false; - } + QueueName = _queueName, + MessageType = typeof(T) + }; + + using var callContext = CallContext.Rent().Set(queueContext); + await _registration.HandleAsync(_mediator, message, callContext, cancellationToken, null).ConfigureAwait(false); } } diff --git a/src/Foundatio.Mediator.Queues/QueueContext.cs b/src/Foundatio.Mediator.Queues/QueueContext.cs new file mode 100644 index 00000000..5c6c2248 --- /dev/null +++ b/src/Foundatio.Mediator.Queues/QueueContext.cs @@ -0,0 +1,74 @@ +namespace Foundatio.Mediator.Queues; + +/// +/// Provides queue-specific context to handler methods during message processing. +/// Injected via so handlers can report progress and +/// renew message timeouts for long-running work. +/// +/// +/// Handlers that accept a parameter will receive it +/// automatically via CallContext injection when processing a queued message: +/// +/// [Queue(Concurrency = 3)] +/// public class LongRunningHandler +/// { +/// public async Task HandleAsync( +/// ProcessLargeFile message, +/// QueueContext queueContext, +/// CancellationToken ct) +/// { +/// foreach (var chunk in GetChunks(message)) +/// { +/// await ProcessChunkAsync(chunk, ct); +/// await queueContext.RenewTimeoutAsync(TimeSpan.FromMinutes(5), ct); +/// await queueContext.ReportProgressAsync(ct); +/// } +/// } +/// } +/// +/// +public class QueueContext +{ + /// + /// The name of the queue this message was received from. + /// + public string QueueName { get; init; } = string.Empty; + + /// + /// The message type being processed. + /// + public Type? MessageType { get; init; } + + /// + /// Delegate invoked by to signal that the handler + /// is still actively working. Set by the consumer infrastructure. + /// + public Func? OnReportProgress { get; init; } + + /// + /// Delegate invoked by to extend the message lock + /// or visibility timeout. Set by the consumer infrastructure. + /// + public Func? OnRenewTimeout { get; init; } + + /// + /// Reports that the handler is still actively processing the message. + /// For transports that support it, this prevents the message from being + /// redelivered or timed out during long-running operations. + /// + /// A cancellation token. + /// A task that completes when the progress report is acknowledged. + public Task ReportProgressAsync(CancellationToken cancellationToken = default) + => OnReportProgress?.Invoke(cancellationToken) ?? Task.CompletedTask; + + /// + /// Extends the message lock or visibility timeout by the specified duration. + /// Use this for long-running handlers to prevent the message from being + /// redelivered to another consumer. + /// + /// The duration to extend the timeout by. + /// A cancellation token. + /// A task that completes when the timeout is renewed. + public Task RenewTimeoutAsync(TimeSpan extension, CancellationToken cancellationToken = default) + => OnRenewTimeout?.Invoke(extension, cancellationToken) ?? Task.CompletedTask; +} diff --git a/src/Foundatio.Mediator.Queues/QueueMiddleware.cs b/src/Foundatio.Mediator.Queues/QueueMiddleware.cs index b3e5fa78..77580c4d 100644 --- a/src/Foundatio.Mediator.Queues/QueueMiddleware.cs +++ b/src/Foundatio.Mediator.Queues/QueueMiddleware.cs @@ -14,8 +14,9 @@ namespace Foundatio.Mediator.Queues; /// /// /// On the process path (when calls back through -/// the mediator), this middleware passes through to next() so the full pipeline -/// (logging, validation, auth, etc.) executes before the handler. +/// the mediator), the presence of a in +/// signals that this is a processing invocation. The middleware passes through to next() +/// so the full pipeline (logging, validation, auth, etc.) executes before the handler. /// /// /// Order is set low so this middleware runs as the outermost ExecuteAsync wrapper, @@ -25,22 +26,11 @@ namespace Foundatio.Mediator.Queues; [Middleware(Order = -100, ExplicitOnly = true)] public class QueueMiddleware { - private static readonly AsyncLocal s_isProcessing = new(); private static readonly ConcurrentDictionary s_publishMethods = new(); private static readonly MethodInfo s_publishTypedMethod = typeof(QueueMiddleware) .GetMethod(nameof(PublishTypedAsync), BindingFlags.NonPublic | BindingFlags.Static)!; - /// - /// Indicates the current async context is processing a message from the bus. - /// Set by to prevent re-enqueuing. - /// - internal static bool IsProcessing - { - get => s_isProcessing.Value; - set => s_isProcessing.Value = value; - } - private readonly IMessageBus _bus; public QueueMiddleware(IMessageBus bus) => _bus = bus; @@ -48,10 +38,11 @@ internal static bool IsProcessing public async ValueTask ExecuteAsync( object message, HandlerExecutionDelegate next, - HandlerExecutionInfo handlerInfo) + HandlerExecutionInfo handlerInfo, + CallContext? callContext) { - // Process path: consumer is calling back through the mediator β€” run the full pipeline - if (IsProcessing) + // Process path: QueueContext in CallContext signals we're processing from the bus + if (callContext?.TryGet(out _) == true) return await next().ConfigureAwait(false); // Enqueue path: publish to the bus and return immediately diff --git a/src/Foundatio.Mediator/FoundatioModuleGenerator.cs b/src/Foundatio.Mediator/FoundatioModuleGenerator.cs index c12d22f5..8878e82b 100644 --- a/src/Foundatio.Mediator/FoundatioModuleGenerator.cs +++ b/src/Foundatio.Mediator/FoundatioModuleGenerator.cs @@ -163,7 +163,7 @@ public static void Execute(SourceProductionContext context, CompilationInfo comp } else { - source.AppendLine($" (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask({handlerClassName}.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)),"); + source.AppendLine($" (mediator, message, callContext, cancellationToken, responseType) => new ValueTask({handlerClassName}.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)),"); source.AppendLine($" {handlerClassName}.UntypedHandle,"); } @@ -237,10 +237,7 @@ public HttpContextAuthorizationContextProvider(Microsoft.AspNetCore.Http.IHttpCo public System.Security.Claims.ClaimsPrincipal? GetCurrentPrincipal() { - // Prefer HttpContext.User for HTTP requests; fall back to Thread.CurrentPrincipal - // for background workers (e.g., queue workers that reconstruct the principal from headers) - return _httpContextAccessor.HttpContext?.User - ?? System.Threading.Thread.CurrentPrincipal as System.Security.Claims.ClaimsPrincipal; + return _httpContextAccessor.HttpContext?.User; } } """); diff --git a/src/Foundatio.Mediator/HandlerGenerator.cs b/src/Foundatio.Mediator/HandlerGenerator.cs index a429eef4..155b4a11 100644 --- a/src/Foundatio.Mediator/HandlerGenerator.cs +++ b/src/Foundatio.Mediator/HandlerGenerator.cs @@ -138,7 +138,7 @@ private static void GenerateHandleMethod(IndentedStringBuilder source, HandlerIn string asyncModifier = (isAsyncMethod && !canSkipAsyncStateMachine) ? "async " : ""; - source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false)") + source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken)") .AppendLine("{"); source.IncrementIndent(); @@ -301,7 +301,7 @@ private static void GenerateHandleItemMethods(IndentedStringBuilder source, Hand string methodReturnType = GetMethodSignatureReturnType(isAsyncMethod, isVoid: false, returnTypeName); string asyncModifier = isAsyncMethod ? "async " : ""; - source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false)"); + source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken)"); source.AppendLine("{"); source.IncrementIndent(); @@ -561,16 +561,9 @@ private static void EmitOpenTelemetrySetup(IndentedStringBuilder source, Handler if (!configuration.OpenTelemetryEnabled) return; - // For notification handlers (void return), include the handler class name to - // distinguish multiple handlers for the same message type in traces. - var activityName = handler.ReturnType.IsVoid - ? $"Handle {handler.Identifier}.{handler.MessageType.Identifier}" - : $"Handle {handler.MessageType.Identifier}"; - - source.AppendLine($"using var activity = MediatorActivitySource.Instance.StartActivity(\"{activityName}\");"); + source.AppendLine($"using var activity = MediatorActivitySource.Instance.StartActivity(\"{handler.MessageType.Identifier}\");"); source.AppendLine($"activity?.SetTag(\"messaging.system\", \"Foundatio.Mediator\");"); source.AppendLine($"activity?.SetTag(\"messaging.message.type\", \"{handler.MessageType.FullName}\");"); - source.AppendLine($"activity?.SetTag(\"messaging.handler\", \"{handler.Identifier}\");"); variables["System.Diagnostics.Activity"] = "activity"; } @@ -595,10 +588,7 @@ private static void EmitAuthorizationCheck(IndentedStringBuilder source, Handler return; source.AppendLine(); - source.AppendLine("// Authorization check (skipped for publish/event dispatch)"); - source.AppendLine("if (!skipAuthorization)"); - source.AppendLine("{"); - source.IncrementIndent(); + source.AppendLine("// Authorization check"); source.AppendLine("var authContextProvider = serviceProvider.GetRequiredService();"); source.AppendLine("var authService = serviceProvider.GetRequiredService();"); source.AppendLine("var principal = authContextProvider.GetCurrentPrincipal();"); @@ -621,8 +611,6 @@ private static void EmitAuthorizationCheck(IndentedStringBuilder source, Handler source.AppendLine("throw new System.UnauthorizedAccessException(authResult.FailureReason ?? \"Authorization failed.\");"); } - source.DecrementIndent(); - source.AppendLine("}"); source.DecrementIndent(); source.AppendLine("}"); source.AppendLine(); @@ -1016,8 +1004,8 @@ private static void GenerateUntypedHandleMethod(IndentedStringBuilder source, Ha bool isAsyncMethod = handler.IsAsync || handler.ReturnType.IsTuple; source.AppendLine(isAsyncMethod - ? "public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false)" - : "public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false)"); + ? "public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType)" + : "public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType)"); source.AppendLine("{"); source.IncrementIndent(); diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt index 630212b3..a92ff60a 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt @@ -39,7 +39,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(Ping)), "PingHandler_Ping_Handler", - (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(PingHandler_Ping_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), + (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(PingHandler_Ping_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), PingHandler_Ping_Handler.UntypedHandle, false, 2147483647, @@ -269,13 +269,12 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class PingHandler_Ping_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, Ping message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) + public static string Handle(Foundatio.Mediator.IMediator mediator, Ping message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) { var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("Handle Ping"); + using var activity = MediatorActivitySource.Instance.StartActivity("Ping"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "Ping"); - activity?.SetTag("messaging.handler", "PingHandler"); string? result = default!; System.Exception? exception = null; @@ -298,14 +297,13 @@ public static class PingHandler_Ping_Handler return result; } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) { var typedMessage = (Ping)message; var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("Handle Ping"); + using var activity = MediatorActivitySource.Instance.StartActivity("Ping"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "Ping"); - activity?.SetTag("messaging.handler", "PingHandler"); string? result = default!; System.Exception? exception = null; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt index 20166e05..b00db101 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt @@ -39,7 +39,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(GetWidget)), "WidgetHandler_GetWidget_Handler", - (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(WidgetHandler_GetWidget_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), + (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(WidgetHandler_GetWidget_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), WidgetHandler_GetWidget_Handler.UntypedHandle, false, 2147483647, @@ -149,12 +149,12 @@ public static class Tests_MediatorEndpoints if (logEndpoints) { System.Action writeLog = System.Console.WriteLine; - writeLog("Foundatio.Mediator mapped 1 endpoint(s) for Tests:"); + writeLog("Foundatio.Mediator mapped 1 endpoint(s):"); writeLog(" GET /api/widgets/{id} β†’ WidgetHandler.Handle(GetWidget) (convention)"); } else { - System.Console.WriteLine("Foundatio.Mediator mapped 1 endpoint(s) for Tests."); + System.Console.WriteLine("Foundatio.Mediator mapped 1 endpoint(s)."); } } } @@ -380,14 +380,14 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class WidgetHandler_GetWidget_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, GetWidget message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) + public static string Handle(Foundatio.Mediator.IMediator mediator, GetWidget message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) { var serviceProvider = (System.IServiceProvider)mediator; var handlerInstance = GetOrCreateHandler(serviceProvider); return handlerInstance.Handle(message); } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) { var typedMessage = (GetWidget)message; var serviceProvider = (System.IServiceProvider)mediator; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt index 0483b9eb..61816c04 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt @@ -42,7 +42,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(ProcessOrder)), "ProcessOrderHandler_ProcessOrder_Handler", - (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), + (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle, false, 2147483647, @@ -272,7 +272,7 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class ProcessOrderHandler_ProcessOrder_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) + public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) { var serviceProvider = (System.IServiceProvider)mediator; var timingMiddleware = GetOrCreateTimingMiddleware(serviceProvider); @@ -306,7 +306,7 @@ public static class ProcessOrderHandler_ProcessOrder_Handler return result; } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) { var typedMessage = (ProcessOrder)message; var serviceProvider = (System.IServiceProvider)mediator; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt index a3486756..b4392b36 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt @@ -39,7 +39,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(Echo)), "EchoHandler_Echo_Handler", - (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(EchoHandler_Echo_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), + (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(EchoHandler_Echo_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), EchoHandler_Echo_Handler.UntypedHandle, false, 2147483647, @@ -269,14 +269,14 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class EchoHandler_Echo_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, Echo message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) + public static string Handle(Foundatio.Mediator.IMediator mediator, Echo message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) { var serviceProvider = (System.IServiceProvider)mediator; var handlerInstance = GetOrCreateHandler(serviceProvider); return handlerInstance.Handle(message); } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) { var typedMessage = (Echo)message; var serviceProvider = (System.IServiceProvider)mediator; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt index f629166b..0c284966 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt @@ -42,7 +42,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(ProcessOrder)), "ProcessOrderHandler_ProcessOrder_Handler", - (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), + (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle, false, 2147483647, @@ -272,13 +272,12 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class ProcessOrderHandler_ProcessOrder_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) + public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) { var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("Handle ProcessOrder"); + using var activity = MediatorActivitySource.Instance.StartActivity("ProcessOrder"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "ProcessOrder"); - activity?.SetTag("messaging.handler", "ProcessOrderHandler"); var loggingMiddleware = GetOrCreateLoggingMiddleware(serviceProvider); System.Diagnostics.Stopwatch? loggingMiddlewareResult = null; @@ -309,14 +308,13 @@ public static class ProcessOrderHandler_ProcessOrder_Handler return result; } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) { var typedMessage = (ProcessOrder)message; var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("Handle ProcessOrder"); + using var activity = MediatorActivitySource.Instance.StartActivity("ProcessOrder"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "ProcessOrder"); - activity?.SetTag("messaging.handler", "ProcessOrderHandler"); var loggingMiddleware = GetOrCreateLoggingMiddleware(serviceProvider); System.Diagnostics.Stopwatch? loggingMiddlewareResult = null; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt index 35c2a39b..e1df7d33 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt @@ -270,7 +270,7 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class GetUserHandler_GetUser_Handler { - public static async System.Threading.Tasks.ValueTask HandleAsync(Foundatio.Mediator.IMediator mediator, GetUser message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) + public static async System.Threading.Tasks.ValueTask HandleAsync(Foundatio.Mediator.IMediator mediator, GetUser message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) { var serviceProvider = (System.IServiceProvider)mediator; string? result = default!; @@ -279,7 +279,7 @@ public static class GetUserHandler_GetUser_Handler return result; } - public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) + public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) { var typedMessage = (GetUser)message; var serviceProvider = (System.IServiceProvider)mediator; From 33c68201eb63ba58cc00495484e20519fbfb48cf Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 4 Apr 2026 01:56:34 -0500 Subject: [PATCH 03/27] Lots of distributed progress --- .vscode/launch.json | 6 + Foundatio.Mediator.slnx | 9 +- aspire.config.json | 5 + .../ModularMonolithSample.slnx | 2 + samples/CleanArchitectureSample/README.md | 87 +- .../src/Api/Api.csproj | 9 +- .../Api/Handlers/ClientEventStreamHandler.cs | 9 +- .../src/Api/Program.cs | 71 +- .../src/Api/Properties/launchSettings.json | 2 +- .../src/Api/appsettings.Development.json | 8 + .../src/Api/appsettings.json | 9 + .../src/AppHost/AppHost.csproj | 25 + .../src/AppHost/Program.cs | 34 + .../AppHost/Properties/launchSettings.json | 17 + .../src/Common.Module/Common.Module.csproj | 3 + .../src/Common.Module/Events/DomainEvents.cs | 16 +- .../Handlers/AuditEventHandler.cs | 5 + .../Handlers/DemoExportJobHandler.cs | 37 + .../Common.Module/Handlers/HealthHandler.cs | 1 - .../Handlers/NotificationEventHandler.cs | 5 + .../Handlers/QueueDashboardHandler.cs | 164 ++ .../Messages/QueueDashboardMessages.cs | 63 + .../Middleware/CachingMiddleware.cs | 175 ++- .../Middleware/ObservabilityMiddleware.cs | 14 +- .../Data/RedisOrderRepository.cs | 131 ++ .../Orders.Module/Handlers/OrderHandler.cs | 2 +- .../src/Orders.Module/Orders.Module.csproj | 4 + .../src/Orders.Module/ServiceConfiguration.cs | 13 +- .../Data/RedisProductRepository.cs | 133 ++ .../ProductCacheInvalidationHandler.cs | 44 + .../Handlers/ProductHandler.cs | 17 +- .../Products.Module/Products.Module.csproj | 4 + .../Products.Module/ServiceConfiguration.cs | 13 +- .../Reports.Module/Handlers/ReportHandler.cs | 1 - .../src/ServiceDefaults/Extensions.cs | 117 ++ .../ServiceDefaults/ServiceDefaults.csproj | 26 + .../src/Web/package-lock.json | 1316 +++++------------ .../src/Web/package.json | 18 +- .../src/Web/src/lib/api/client.ts | 7 +- .../src/Web/src/lib/api/index.ts | 1 + .../src/Web/src/lib/api/queues.ts | 22 + .../src/Web/src/lib/api/reports.ts | 8 +- .../src/lib/components/layout/Sidebar.svelte | 1 + .../Web/src/lib/stores/eventstream.svelte.ts | 2 +- .../src/Web/src/lib/types/index.ts | 1 + .../src/Web/src/lib/types/queue.ts | 53 + .../src/Web/src/routes/orders/+page.svelte | 10 +- .../src/Web/src/routes/products/+page.svelte | 10 +- .../src/Web/src/routes/queues/+page.svelte | 360 +++++ .../src/Web/vite.config.ts | 100 +- samples/ConsoleSample/ConsoleSample.csproj | 3 +- .../ConsoleSample/Handlers/QueueHandler.cs | 2 +- samples/ConsoleSample/Program.cs | 5 +- samples/ConsoleSample/SampleRunner.cs | 8 +- samples/ConsoleSample/ServiceConfiguration.cs | 27 +- .../HandlerRegistration.cs | 6 +- .../HandlerRegistry.cs | 109 +- .../Foundatio.Mediator.Distributed.Aws.csproj | 17 + .../SnsSqsPubSubClient.cs | 344 +++++ .../SnsSqsPubSubClientOptions.cs | 43 + .../SqsQueueClient.cs | 315 ++++ .../SqsQueueClientOptions.cs | 26 + .../SqsServiceExtensions.cs | 82 + ...oundatio.Mediator.Distributed.Redis.csproj | 16 + .../RedisJobStateStoreOptions.cs | 18 + .../RedisQueueJobStateStore.cs | 284 ++++ .../RedisServiceExtensions.cs | 39 + .../AssemblyInfo.cs | 0 .../DistributedContext.cs | 34 + .../DistributedInfrastructureInitializer.cs | 49 + .../DistributedNotificationOptions.cs | 43 + .../DistributedNotificationWorker.cs | 254 ++++ .../DistributedServiceExtensions.cs | 241 +++ .../Foundatio.Mediator.Distributed.csproj} | 6 +- .../Handlers/QueueDashboardHandler.cs | 154 ++ .../IDistributedNotification.cs | 14 + .../IPubSubClient.cs | 33 + .../IQueueClient.cs | 67 + .../IQueueJobStateStore.cs | 91 ++ .../IQueueWorkerRegistry.cs | 17 + .../InMemoryPubSubClient.cs | 96 ++ .../InMemoryQueueClient.cs | 197 +++ .../InMemoryQueueJobStateStore.cs | 153 ++ .../MessageHeaders.cs | 70 + .../Messages/QueueDashboardMessages.cs | 72 + .../PubSubMessage.cs | 17 + .../QueueAttribute.cs | 88 ++ .../QueueContext.cs | 120 ++ .../QueueEntry.cs | 21 + .../QueueJobState.cs | 93 ++ .../QueueMessage.cs | 50 + .../QueueMiddleware.cs | 130 ++ .../QueueRetryPolicy.cs | 23 + .../QueueStats.cs | 33 + .../QueueWorker.cs | 484 ++++++ .../QueueWorkerInfo.cs | 80 + .../QueueWorkerOptions.cs | 84 ++ .../QueueWorkerRegistry.cs | 22 + .../MediatorConsumer.cs | 47 - .../QueueAttribute.cs | 68 - src/Foundatio.Mediator.Queues/QueueContext.cs | 74 - .../QueueMiddleware.cs | 59 - .../QueueServiceExtensions.cs | 102 -- .../FoundatioModuleGenerator.cs | 7 +- src/Foundatio.Mediator/HandlerGenerator.cs | 24 +- ...atio.Mediator.Distributed.Aws.Tests.csproj | 30 + .../GlobalUsings.cs | 1 + .../LocalStackFixture.cs | 40 + .../SnsSqsPubSubClientTests.cs | 196 +++ .../SqsQueueClientTests.cs | 251 ++++ .../ComputeRetryDelayTests.cs | 119 ++ ...DistributedNotificationIntegrationTests.cs | 368 +++++ ...oundatio.Mediator.Distributed.Tests.csproj | 43 + .../GlobalUsings.cs | 1 + .../InMemoryPubSubClientTests.cs | 156 ++ .../InMemoryQueueClientTests.cs | 289 ++++ .../InMemoryQueueJobStateStoreTests.cs | 223 +++ .../QueueClientTestBase.cs | 243 +++ .../QueueWorkerIntegrationTests.cs | 433 ++++++ .../QueueWorkerJobTrackingTests.cs | 359 +++++ ...DefaultStaticHandler_WithOTel.verified.txt | 12 +- ...ationTests.EndpointGeneration.verified.txt | 10 +- ...HandlerWithMiddlewarePipeline.verified.txt | 6 +- ...onTests.InterceptorGeneration.verified.txt | 6 +- ...reWithBeforeStateButNoFinally.verified.txt | 12 +- ...nTests.ScopedDIHandler_NoOTel.verified.txt | 4 +- 126 files changed, 9075 insertions(+), 1478 deletions(-) create mode 100644 aspire.config.json create mode 100644 samples/CleanArchitectureSample/src/Api/appsettings.Development.json create mode 100644 samples/CleanArchitectureSample/src/Api/appsettings.json create mode 100644 samples/CleanArchitectureSample/src/AppHost/AppHost.csproj create mode 100644 samples/CleanArchitectureSample/src/AppHost/Program.cs create mode 100644 samples/CleanArchitectureSample/src/AppHost/Properties/launchSettings.json create mode 100644 samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs create mode 100644 samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs create mode 100644 samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs create mode 100644 samples/CleanArchitectureSample/src/Orders.Module/Data/RedisOrderRepository.cs create mode 100644 samples/CleanArchitectureSample/src/Products.Module/Data/RedisProductRepository.cs create mode 100644 samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductCacheInvalidationHandler.cs create mode 100644 samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs create mode 100644 samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj create mode 100644 samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts create mode 100644 samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts create mode 100644 samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte create mode 100644 src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj create mode 100644 src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs create mode 100644 src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClientOptions.cs create mode 100644 src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs create mode 100644 src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs create mode 100644 src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs create mode 100644 src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj create mode 100644 src/Foundatio.Mediator.Distributed.Redis/RedisJobStateStoreOptions.cs create mode 100644 src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs create mode 100644 src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs rename src/{Foundatio.Mediator.Queues => Foundatio.Mediator.Distributed}/AssemblyInfo.cs (100%) create mode 100644 src/Foundatio.Mediator.Distributed/DistributedContext.cs create mode 100644 src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs create mode 100644 src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs create mode 100644 src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs create mode 100644 src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs rename src/{Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj => Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj} (58%) create mode 100644 src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs create mode 100644 src/Foundatio.Mediator.Distributed/IDistributedNotification.cs create mode 100644 src/Foundatio.Mediator.Distributed/IPubSubClient.cs create mode 100644 src/Foundatio.Mediator.Distributed/IQueueClient.cs create mode 100644 src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs create mode 100644 src/Foundatio.Mediator.Distributed/IQueueWorkerRegistry.cs create mode 100644 src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs create mode 100644 src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs create mode 100644 src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs create mode 100644 src/Foundatio.Mediator.Distributed/MessageHeaders.cs create mode 100644 src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs create mode 100644 src/Foundatio.Mediator.Distributed/PubSubMessage.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueAttribute.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueContext.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueEntry.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueJobState.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueMessage.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueMiddleware.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueRetryPolicy.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueStats.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueWorker.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueWorkerOptions.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueWorkerRegistry.cs delete mode 100644 src/Foundatio.Mediator.Queues/MediatorConsumer.cs delete mode 100644 src/Foundatio.Mediator.Queues/QueueAttribute.cs delete mode 100644 src/Foundatio.Mediator.Queues/QueueContext.cs delete mode 100644 src/Foundatio.Mediator.Queues/QueueMiddleware.cs delete mode 100644 src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj create mode 100644 tests/Foundatio.Mediator.Distributed.Aws.Tests/GlobalUsings.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Aws.Tests/LocalStackFixture.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/GlobalUsings.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerIntegrationTests.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b872566..ebeb61cb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,6 +10,12 @@ "request": "launch", "projectPath": "${workspaceFolder}/samples/CleanArchitectureSample/src/Api/Api.csproj" }, + { + "name": "Clean Architecture Sample (Distributed)", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj" + }, { "name": "Console Sample", "type": "dotnet", diff --git a/Foundatio.Mediator.slnx b/Foundatio.Mediator.slnx index 260d4496..be0408b5 100644 --- a/Foundatio.Mediator.slnx +++ b/Foundatio.Mediator.slnx @@ -12,6 +12,8 @@ + + @@ -21,9 +23,14 @@ - + + + + + + diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 00000000..cf5d033a --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "samples/CleanArchitectureSample/src/AppHost/AppHost.csproj" + } +} \ No newline at end of file diff --git a/samples/CleanArchitectureSample/ModularMonolithSample.slnx b/samples/CleanArchitectureSample/ModularMonolithSample.slnx index b6bcc093..3e21b4a5 100644 --- a/samples/CleanArchitectureSample/ModularMonolithSample.slnx +++ b/samples/CleanArchitectureSample/ModularMonolithSample.slnx @@ -6,5 +6,7 @@ + + diff --git a/samples/CleanArchitectureSample/README.md b/samples/CleanArchitectureSample/README.md index 8f7a0842..7a0d0a34 100644 --- a/samples/CleanArchitectureSample/README.md +++ b/samples/CleanArchitectureSample/README.md @@ -24,6 +24,9 @@ A working modular monolith that showcases Foundatio.Mediator's features in a rea | **Result pattern** | `Result.NotFound()`, `Result.Invalid()`, `Result.Error()` β€” no exceptions for business logic | | **Streaming SSE endpoint** | `ClientEventStreamHandler` turns `IDispatchToClient` events into a real-time SSE stream | | **Assembly configuration** | `[assembly: MediatorConfiguration(AuthorizationRequired = true, ...)]` per module | +| **Distributed notifications** | Domain events implement `IDistributedNotification` β€” fan out across all replicas via SNS+SQS | +| **Async queue handlers** | `[Queue]` on `AuditEventHandler` / `NotificationEventHandler` β€” processed via SQS | +| **Aspire orchestration** | `AppHost` runs 3 API replicas + LocalStack (SQS/SNS) for local development | ## Project Structure @@ -31,10 +34,10 @@ A working modular monolith that showcases Foundatio.Mediator's features in a rea src/ β”œβ”€β”€ Common.Module/ # Cross-cutting middleware, events, shared services β”‚ β”œβ”€β”€ Events/ -β”‚ β”‚ └── DomainEvents.cs # OrderCreated, ProductUpdated, etc. +β”‚ β”‚ └── DomainEvents.cs # OrderCreated, ProductUpdated, etc. (IDistributedNotification) β”‚ β”œβ”€β”€ Handlers/ -β”‚ β”‚ β”œβ”€β”€ AuditEventHandler.cs # Reacts to all domain events -β”‚ β”‚ β”œβ”€β”€ NotificationEventHandler.cs # Sends notifications on events +β”‚ β”‚ β”œβ”€β”€ AuditEventHandler.cs # [Queue] β€” async audit logging via SQS +β”‚ β”‚ β”œβ”€β”€ NotificationEventHandler.cs # [Queue] β€” async notification delivery via SQS β”‚ β”‚ └── HealthHandler.cs # [HandlerAllowAnonymous] health check β”‚ β”œβ”€β”€ Middleware/ β”‚ β”‚ β”œβ”€β”€ ObservabilityMiddleware.cs # Before/After/Finally with Stopwatch state @@ -70,10 +73,16 @@ src/ β”‚ └── ServiceConfiguration.cs β”‚ β”œβ”€β”€ Api/ # ASP.NET Core composition root -β”‚ β”œβ”€β”€ Program.cs # AddMediator(), MapMediatorEndpoints() +β”‚ β”œβ”€β”€ Program.cs # AddMediator(), SQS/SNS, Aspire, MapMediatorEndpoints() β”‚ └── Handlers/ β”‚ └── ClientEventStreamHandler.cs # Streaming SSE endpoint for real-time events β”‚ +β”œβ”€β”€ AppHost/ # Aspire orchestrator (3 API replicas + LocalStack) +β”‚ └── Program.cs +β”‚ +β”œβ”€β”€ ServiceDefaults/ # Aspire service defaults (OpenTelemetry, health checks) +β”‚ └── Extensions.cs +β”‚ └── Web/ # SvelteKit SPA frontend ``` @@ -401,7 +410,53 @@ source.onmessage = (e) => { }; ``` -### 10. Result Pattern +### 10. Distributed Notifications (SNS+SQS Fan-Out) + +Domain events implement `IDistributedNotification` so they automatically fan out to all replicas in a scale-out cluster. When one replica handles a request and publishes `OrderCreated`, every other replica also receives it β€” enabling SSE streams, cache invalidation, and other real-time features to work across all instances: + +```csharp +// DomainEvents.cs β€” IDistributedNotification extends INotification +public record OrderCreated(string OrderId, string CustomerId, decimal Amount, DateTime CreatedAt) + : IDistributedNotification, IDispatchToClient; +``` + +The `DistributedNotificationWorker` background service bridges local mediator pub/sub with the remote SNS+SQS bus: + +- **Outbound**: subscribes to local `IDistributedNotification` events β†’ serializes β†’ publishes to SNS topic +- **Inbound**: subscribes to per-node SQS queue (fed by SNS) β†’ deserializes β†’ publishes locally via `mediator.PublishAsync()` +- **Loop prevention**: two layers prevent infinite re-broadcast β€” HostId header (skip self-delivery) + reference identity set (skip re-broadcast of bus-received messages) + +### 11. Async Queue Handlers (SQS) + +Event handlers decorated with `[Queue]` offload processing to an SQS queue, keeping the request path fast. The audit and notification handlers both use this pattern: + +```csharp +[Queue] +public class AuditEventHandler(IAuditService auditService, ILogger logger) +{ + // This runs asynchronously via SQS β€” the handler that published OrderCreated + // returns immediately without waiting for audit logging to complete + public async Task HandleAsync(OrderCreated evt, CancellationToken cancellationToken) { ... } +} +``` + +The distributed infrastructure is wired up in `Program.cs`: + +```csharp +// AWS clients (LocalStack via Aspire or real AWS) +builder.Services.AddSingleton(_ => new AmazonSQSClient(...)); +builder.Services.AddSingleton(_ => new AmazonSimpleNotificationServiceClient(...)); + +// Async queue processing via SQS +builder.Services.AddMediatorSqs(); +builder.Services.AddMediatorDistributed(); + +// Distributed notification fan-out via SNS+SQS +builder.Services.AddSnsSqsPubSubClient(); +builder.Services.AddMediatorDistributedNotifications(); +``` + +### 12. Result Pattern All handlers return `Result` for business logic outcomes instead of throwing exceptions: @@ -445,8 +500,26 @@ Common.Module (no module dependencies) - .NET 10 SDK - Node.js 20+ (for the frontend) +- Docker (for Aspire + LocalStack) -### Quick Start +### Quick Start (with Aspire) + +The recommended way to run uses Aspire to orchestrate 3 API replicas with a LocalStack container providing SQS and SNS: + +```bash +cd samples/CleanArchitectureSample/src/AppHost +dotnet run +``` + +This starts: + +- **3 API replicas** β€” demonstrating distributed notification fan-out +- **LocalStack** β€” provides SQS (async queue processing) and SNS (pub/sub notifications) +- **Aspire Dashboard** β€” view traces, logs, and metrics at the URL shown in terminal output + +### Quick Start (standalone) + +To run a single instance without Aspire/Docker: 1. **Install frontend dependencies** (first time only): @@ -460,6 +533,8 @@ Common.Module (no module dependencies) - **Visual Studio**: Set `Api` as startup project and press F5 - **CLI**: `dotnet run --project samples/CleanArchitectureSample/src/Api` +Without the `AWS:ServiceURL` environment variable, the app falls back to in-memory queue and pub/sub client implementations. + The SPA Proxy starts the Vite dev server automatically. ### URLs diff --git a/samples/CleanArchitectureSample/src/Api/Api.csproj b/samples/CleanArchitectureSample/src/Api/Api.csproj index 66ec3641..efdbf32e 100644 --- a/samples/CleanArchitectureSample/src/Api/Api.csproj +++ b/samples/CleanArchitectureSample/src/Api/Api.csproj @@ -28,13 +28,20 @@ - + + + + + + + + diff --git a/samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs b/samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs index d2b87f0b..21768522 100644 --- a/samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs +++ b/samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs @@ -16,14 +16,11 @@ public record ClientEvent(string EventType, object Data); public record GetEventStream; /// -/// Streaming handler that subscribes to all IDispatchToClient notifications via the -/// mediator's built-in subscription support and streams them as SSE events. +/// Subscribe to real-time domain events via Server-Sent Events. /// -public class ClientEventStreamHandler(IMediator mediator) +public class EventHandler(IMediator mediator) { - [HandlerEndpoint( - Streaming = EndpointStreaming.ServerSentEvents, - Summary = "Subscribe to real-time domain events via Server-Sent Events")] + [HandlerEndpoint(Streaming = EndpointStreaming.ServerSentEvents)] public async IAsyncEnumerable Handle( GetEventStream message, [EnumeratorCancellation] CancellationToken cancellationToken) diff --git a/samples/CleanArchitectureSample/src/Api/Program.cs b/samples/CleanArchitectureSample/src/Api/Program.cs index f8dd949c..8e616144 100644 --- a/samples/CleanArchitectureSample/src/Api/Program.cs +++ b/samples/CleanArchitectureSample/src/Api/Program.cs @@ -1,13 +1,21 @@ using Common.Module; using Foundatio.Mediator; +using Foundatio.Mediator.Distributed; +using Foundatio.Mediator.Distributed.Aws; using Microsoft.AspNetCore.Authentication.Cookies; using Orders.Module; using Products.Module; using Reports.Module; +using Foundatio.Mediator.Distributed.Redis; using Scalar.AspNetCore; +using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); +// Aspire service defaults (OpenTelemetry, health checks, service discovery) +// Works fine both with and without the Aspire AppHost +builder.AddServiceDefaults(); + builder.Services.AddHttpContextAccessor(); builder.Services.AddOpenApi(); @@ -37,6 +45,39 @@ // Add Foundatio.Mediator β€” all referenced module assemblies are auto-discovered builder.Services.AddMediator(); +// ── Redis + HybridCache (L1 in-memory + L2 Redis distributed cache) ── +var redisConnection = builder.Configuration.GetConnectionString("redis"); +if (!string.IsNullOrEmpty(redisConnection)) +{ + // Register IConnectionMultiplexer for repository persistence + builder.Services.AddSingleton( + ConnectionMultiplexer.Connect(redisConnection)); + + // Register IDistributedCache backed by Redis (L2 for HybridCache) + builder.Services.AddStackExchangeRedisCache(options => + options.Configuration = redisConnection); + + // Use Redis for queue job state tracking (shared across all replicas) + builder.Services.AddMediatorRedisJobStateStore(); +} + +// HybridCache provides L1 (in-memory) + L2 (distributed) caching. +// When Redis is configured, the L2 backs all nodes; without it, L1-only. +builder.Services.AddHybridCache(); + +// ── AWS SQS/SNS (only when running under Aspire with LocalStack or real AWS) ── +var awsServiceUrl = builder.Configuration["AWS:ServiceURL"]; +if (!string.IsNullOrEmpty(awsServiceUrl)) +{ + // These usings are only needed in the SQS codepath - import dynamically + // to keep the standalone path clean + ConfigureAwsDistributed(builder.Services, awsServiceUrl); +} + +// Wire up distributed infrastructure (falls back to in-memory when no SQS/SNS is registered) +builder.Services.AddMediatorDistributed(); +builder.Services.AddMediatorDistributedNotifications(); + // Add module services // Order matters: Common.Module provides cross-cutting services that other modules may depend on builder.Services.AddCommonModule(); @@ -44,11 +85,11 @@ builder.Services.AddProductsModule(); builder.Services.AddReportsModule(); -// Cross-module event handlers (AuditEventHandler, NotificationEventHandler) are now -// in Common.Module and will be discovered automatically via the source generator - var app = builder.Build(); +// Health check endpoints +app.MapDefaultEndpoints(); + // Serve static files from the SPA app.UseDefaultFiles(); app.MapStaticAssets(); @@ -69,4 +110,28 @@ app.Run(); +// ── Extracted so AWS SDK types are only referenced when AWS:ServiceURL is set ── +static void ConfigureAwsDistributed(IServiceCollection services, string serviceUrl) +{ + // LocalStack doesn't require real credentials β€” use dummy ones to bypass + // the default credential chain which fails without AWS config/env vars + var credentials = new Amazon.Runtime.BasicAWSCredentials("test", "test"); + var sqsConfig = new Amazon.SQS.AmazonSQSConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = "us-east-1" + }; + services.AddSingleton(_ => new Amazon.SQS.AmazonSQSClient(credentials, sqsConfig)); + + var snsConfig = new Amazon.SimpleNotificationService.AmazonSimpleNotificationServiceConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = "us-east-1" + }; + services.AddSingleton( + _ => new Amazon.SimpleNotificationService.AmazonSimpleNotificationServiceClient(credentials, snsConfig)); + + services.AddMediatorSqs(); + services.AddSnsSqsPubSubClient(); +} diff --git a/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json b/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json index d3841201..6e539d41 100644 --- a/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json +++ b/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json @@ -9,7 +9,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" }, - "applicationUrl": "https://localhost:58702;http://localhost:58703" + "applicationUrl": "https://localhost:5099;http://localhost:5098" } } } diff --git a/samples/CleanArchitectureSample/src/Api/appsettings.Development.json b/samples/CleanArchitectureSample/src/Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/CleanArchitectureSample/src/Api/appsettings.json b/samples/CleanArchitectureSample/src/Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/CleanArchitectureSample/src/Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj b/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj new file mode 100644 index 00000000..387319b7 --- /dev/null +++ b/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj @@ -0,0 +1,25 @@ + + + + + + Exe + net10.0 + enable + true + true + false + $(NoWarn);ASPIRECERTIFICATES001 + + + + + + + + + + + + + diff --git a/samples/CleanArchitectureSample/src/AppHost/Program.cs b/samples/CleanArchitectureSample/src/AppHost/Program.cs new file mode 100644 index 00000000..961b658f --- /dev/null +++ b/samples/CleanArchitectureSample/src/AppHost/Program.cs @@ -0,0 +1,34 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// LocalStack provides SQS + SNS for local development +var localstack = builder.AddContainer("localstack", "localstack/localstack", "latest") + .WithHttpEndpoint(targetPort: 4566, name: "main") + .WithHttpHealthCheck("/_localstack/health", endpointName: "main") + .WithEnvironment("SERVICES", "sqs,sns"); + +// Redis for shared persistence and distributed caching +var redis = builder.AddRedis("redis"); + +// The API project with 3 replicas to demonstrate distributed pub/sub fan-out +var api = builder.AddProject("api") + // Expose dynamic API endpoints externally so dashboard links and references resolve correctly. + .WithExternalHttpEndpoints() + .WithReplicas(3) + .WaitFor(localstack) + .WaitFor(redis) + .WithReference(localstack.GetEndpoint("main")) + .WithReference(redis) + .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("main")) + // Disable per-replica SpaProxy startup; AppHost owns a single frontend process. + .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", string.Empty); + +// Run a single Vite frontend for all API replicas in distributed mode. +builder.AddViteApp("web", "../Web") + .WithHttpsEndpoint(port: 5199, env: "PORT") + .WithHttpsDeveloperCertificate() + .WithExternalHttpEndpoints() + .WithReference(api) + // Provide an explicit API proxy target (not VITE_ prefixed so it stays server-side). + .WithEnvironment("API_PROXY_TARGET", api.GetEndpoint("https")); + +builder.Build().Run(); diff --git a/samples/CleanArchitectureSample/src/AppHost/Properties/launchSettings.json b/samples/CleanArchitectureSample/src/AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..63afb4eb --- /dev/null +++ b/samples/CleanArchitectureSample/src/AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17039;http://localhost:15141", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21065", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22020" + } + } + } +} diff --git a/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj b/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj index ff9c520a..0ad1ffdb 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj +++ b/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj @@ -28,6 +28,7 @@ + @@ -36,6 +37,8 @@ Include="..\..\..\..\src\Foundatio.Mediator.Abstractions\Foundatio.Mediator.Abstractions.csproj" /> + diff --git a/samples/CleanArchitectureSample/src/Common.Module/Events/DomainEvents.cs b/samples/CleanArchitectureSample/src/Common.Module/Events/DomainEvents.cs index e903ff41..d5a0c06d 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Events/DomainEvents.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Events/DomainEvents.cs @@ -1,14 +1,16 @@ using Foundatio.Mediator; +using Foundatio.Mediator.Distributed; namespace Common.Module.Events; // Order Events - Published by Orders.Module, consumed by cross-cutting handlers -public record OrderCreated(string OrderId, string CustomerId, decimal Amount, DateTime CreatedAt) : INotification, IDispatchToClient; -public record OrderUpdated(string OrderId, decimal Amount, string Status, DateTime UpdatedAt) : INotification, IDispatchToClient; -public record OrderDeleted(string OrderId, DateTime DeletedAt) : INotification, IDispatchToClient; +// IDistributedNotification ensures these fan out to all replicas via SNS/SQS +public record OrderCreated(string OrderId, string CustomerId, decimal Amount, DateTime CreatedAt) : IDistributedNotification, IDispatchToClient; +public record OrderUpdated(string OrderId, decimal Amount, string Status, DateTime UpdatedAt) : IDistributedNotification, IDispatchToClient; +public record OrderDeleted(string OrderId, DateTime DeletedAt) : IDistributedNotification, IDispatchToClient; // Product Events - Published by Products.Module, consumed by cross-cutting handlers -public record ProductCreated(string ProductId, string Name, decimal Price, DateTime CreatedAt) : INotification, IDispatchToClient; -public record ProductUpdated(string ProductId, string Name, decimal Price, string Status, DateTime UpdatedAt) : INotification, IDispatchToClient; -public record ProductDeleted(string ProductId, DateTime DeletedAt) : INotification, IDispatchToClient; -public record ProductStockChanged(string ProductId, int OldQuantity, int NewQuantity, DateTime ChangedAt) : INotification; +public record ProductCreated(string ProductId, string Name, decimal Price, DateTime CreatedAt) : IDistributedNotification, IDispatchToClient; +public record ProductUpdated(string ProductId, string Name, decimal Price, string Status, DateTime UpdatedAt) : IDistributedNotification, IDispatchToClient; +public record ProductDeleted(string ProductId, DateTime DeletedAt) : IDistributedNotification, IDispatchToClient; +public record ProductStockChanged(string ProductId, int OldQuantity, int NewQuantity, DateTime ChangedAt) : IDistributedNotification; diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs index 93417cea..3edc2783 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs @@ -1,5 +1,6 @@ using Common.Module.Events; using Common.Module.Services; +using Foundatio.Mediator.Distributed; using Microsoft.Extensions.Logging; namespace Common.Module.Handlers; @@ -10,7 +11,11 @@ namespace Common.Module.Handlers; /// - Orders.Module and Products.Module don't know this handler exists /// - They just publish events; subscribers react independently /// - Adding new audit capabilities requires no changes to source modules +/// +/// Decorated with [Queue] so audit logging is processed asynchronously via SQS, +/// keeping the request path fast. /// +[Queue] public class AuditEventHandler(IAuditService auditService, ILogger logger) { // Order events diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs new file mode 100644 index 00000000..7893b5fe --- /dev/null +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs @@ -0,0 +1,37 @@ +using Common.Module.Messages; +using Foundatio.Mediator; +using Foundatio.Mediator.Distributed; +using Microsoft.Extensions.Logging; + +namespace Common.Module.Handlers; + +/// +/// A demo queue handler with progress tracking enabled. +/// Simulates a long-running export/report generation job that reports progress +/// and supports cancellation via the queue job state store. +/// +[Queue(TrackProgress = true, Concurrency = 5)] +public class DemoExportJobHandler(ILogger logger) +{ + public async Task HandleAsync(DemoExportJob message, QueueContext queueContext, CancellationToken ct) + { + logger.LogInformation("Starting demo export job ({Steps} steps, {Delay}ms each)", message.Steps, message.StepDelayMs); + + for (int i = 1; i <= message.Steps; i++) + { + ct.ThrowIfCancellationRequested(); + + // Simulate work + await Task.Delay(message.StepDelayMs, ct).ConfigureAwait(false); + + int percent = (int)((double)i / message.Steps * 100); + string stepMessage = $"Processing step {i} of {message.Steps}"; + await queueContext.ReportProgressAsync(percent, stepMessage, ct).ConfigureAwait(false); + + logger.LogDebug("Demo export: {Percent}% - {Message}", percent, stepMessage); + } + + logger.LogInformation("Demo export job completed successfully"); + return Result.Ok(); + } +} diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/HealthHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/HealthHandler.cs index 10c1a9fa..e9085c81 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/HealthHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/HealthHandler.cs @@ -12,7 +12,6 @@ namespace Common.Module.Handlers; /// monitors, and readiness probes. /// [HandlerAllowAnonymous] -[HandlerEndpointGroup("Health")] public class HealthHandler { public HealthStatusResponse Handle(GetHealthStatus query) => diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs index fa9e1201..bfb45a10 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs @@ -1,5 +1,6 @@ using Common.Module.Events; using Common.Module.Services; +using Foundatio.Mediator.Distributed; using Microsoft.Extensions.Logging; namespace Common.Module.Handlers; @@ -11,7 +12,11 @@ namespace Common.Module.Handlers; /// - Low stock alerts when inventory changes /// - Order confirmations when orders are created /// - Status updates when orders change +/// +/// Decorated with [Queue] so notification delivery is processed asynchronously +/// via SQS, keeping the request path fast. /// +[Queue] public class NotificationEventHandler(INotificationService notificationService, ILogger logger) { private const int LowStockThreshold = 10; diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs new file mode 100644 index 00000000..e2ca9211 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs @@ -0,0 +1,164 @@ +using Common.Module.Messages; +using Foundatio.Mediator; +using Foundatio.Mediator.Distributed; + +namespace Common.Module.Handlers; + +/// +/// Queue dashboard handler β€” exposes queue workers, job tracking, and cancellation +/// as mediator endpoints under /api/queues. +/// +[HandlerEndpointGroup("Queues")] +[HandlerAllowAnonymous] +public class QueueDashboardHandler +{ + private readonly IQueueWorkerRegistry _registry; + private readonly IQueueClient _queueClient; + private readonly IQueueJobStateStore? _stateStore; + + public QueueDashboardHandler(IQueueWorkerRegistry registry, IQueueClient queueClient, IQueueJobStateStore? stateStore = null) + { + _registry = registry; + _queueClient = queueClient; + _stateStore = stateStore; + } + + public async Task>> HandleAsync(GetQueues query, CancellationToken ct) + { + var workers = _registry.GetWorkers(); + var results = new List(workers.Count); + + foreach (var worker in workers) + { + QueueStats? stats = null; + try { stats = await _queueClient.GetQueueStatsAsync(worker.QueueName, ct).ConfigureAwait(false); } + catch { /* Transport may not support stats */ } + + results.Add(await ToSummaryAsync(worker, stats, ct).ConfigureAwait(false)); + } + + return results; + } + + public async Task> HandleAsync(GetQueue query, CancellationToken ct) + { + var worker = _registry.GetWorker(query.QueueName); + if (worker is null) + return Result.NotFound($"Queue worker '{query.QueueName}' not found"); + + QueueStats? stats = null; + try { stats = await _queueClient.GetQueueStatsAsync(query.QueueName, ct).ConfigureAwait(false); } + catch { /* Transport may not support stats */ } + + return await ToSummaryAsync(worker, stats, ct).ConfigureAwait(false); + } + + public async Task> HandleAsync(GetJobDashboard query, CancellationToken ct) + { + if (_stateStore is null) + return Result.Error("Job state tracking is not configured."); + + var queuedCount = await _stateStore.GetJobCountByStatusAsync(query.QueueName, QueueJobStatus.Queued, ct).ConfigureAwait(false); + + var activeJobs = await _stateStore.GetJobsByStatusAsync( + query.QueueName, [QueueJobStatus.Processing], 0, 200, ct).ConfigureAwait(false); + + var recentJobs = await _stateStore.GetJobsByStatusAsync( + query.QueueName, [QueueJobStatus.Completed, QueueJobStatus.Failed, QueueJobStatus.Cancelled], + 0, query.RecentTerminalCount ?? 20, ct).ConfigureAwait(false); + + return new JobDashboardView + { + QueuedCount = queuedCount, + ActiveJobs = activeJobs.Select(ToJobSummary).ToList(), + RecentJobs = recentJobs.Select(ToJobSummary).ToList() + }; + } + + public async Task> HandleAsync(GetQueueJobDetail query, CancellationToken ct) + { + if (_stateStore is null) + return Result.Error("Job state tracking is not configured."); + + var state = await _stateStore.GetJobStateAsync(query.JobId, ct).ConfigureAwait(false); + if (state is null) + return Result.NotFound($"Job '{query.JobId}' not found"); + + return ToJobSummary(state); + } + + public async Task> HandleAsync(CancelJob command, CancellationToken ct) + { + if (_stateStore is null) + return Result.Error("Job state tracking is not configured."); + + var requested = await _stateStore.RequestCancellationAsync(command.JobId, ct).ConfigureAwait(false); + if (!requested) + return Result.NotFound($"Job '{command.JobId}' not found or already in a terminal state"); + + return new JobCancellationResult(command.JobId, true); + } + + public async Task> HandleAsync(EnqueueDemoJob command, IMediator mediator, CancellationToken ct) + { + var count = Math.Clamp(command.Count, 1, 100); + string? lastJobId = null; + + for (int i = 0; i < count; i++) + { + var result = await mediator.InvokeAsync(new DemoExportJob(command.Steps, command.StepDelayMs), ct); + if (result.Status == ResultStatus.Accepted && !string.IsNullOrEmpty(result.Message)) + lastJobId = result.Message; + } + + return new DemoJobEnqueued(lastJobId ?? string.Empty); + } + + private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueStats? stats, CancellationToken ct) + { + IReadOnlyDictionary? counters = null; + long? processingCount = null; + if (_stateStore is not null) + { + try { counters = await _stateStore.GetCountersAsync(worker.QueueName, ct).ConfigureAwait(false); } + catch { /* State store may not be available */ } + + if (worker.TrackProgress) + { + try { processingCount = await _stateStore.GetJobCountByStatusAsync(worker.QueueName, QueueJobStatus.Processing, ct).ConfigureAwait(false); } + catch { /* State store may not be available */ } + } + } + + return new QueueSummary + { + QueueName = worker.QueueName, + MessageType = worker.MessageTypeName, + Concurrency = worker.Concurrency, + MaxRetries = worker.MaxRetries, + RetryPolicy = worker.RetryPolicy.ToString(), + TrackProgress = worker.TrackProgress, + IsRunning = worker.IsRunning, + MessagesProcessed = counters?.GetValueOrDefault("processed") ?? worker.MessagesProcessed, + MessagesFailed = counters?.GetValueOrDefault("failed") ?? worker.MessagesFailed, + MessagesDeadLettered = counters?.GetValueOrDefault("dead_lettered") ?? worker.MessagesDeadLettered, + ActiveCount = stats?.ActiveCount ?? 0, + DeadLetterCount = stats?.DeadLetterCount ?? 0, + InFlightCount = processingCount ?? stats?.InFlightCount ?? 0 + }; + } + + private static JobSummary ToJobSummary(QueueJobState s) => new() + { + JobId = s.JobId, + QueueName = s.QueueName, + MessageType = s.MessageType, + Status = s.Status.ToString(), + Progress = s.Progress, + ProgressMessage = s.ProgressMessage, + CreatedUtc = s.CreatedUtc, + StartedUtc = s.StartedUtc, + CompletedUtc = s.CompletedUtc, + ErrorMessage = s.ErrorMessage + }; +} diff --git a/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs b/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs new file mode 100644 index 00000000..ece18620 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs @@ -0,0 +1,63 @@ +namespace Common.Module.Messages; + +// ── Queue Dashboard Queries ── + +public record GetQueues; + +public record GetQueue(string QueueName); + +public record GetJobDashboard(string QueueName, int? RecentTerminalCount = 20); + +public record GetQueueJobDetail(string JobId); + +public record CancelJob(string JobId); + +public record EnqueueDemoJob(int Count = 1, int Steps = 20, int StepDelayMs = 1500); + +// ── DTOs ── + +public record QueueSummary +{ + public required string QueueName { get; init; } + public required string MessageType { get; init; } + public int Concurrency { get; init; } + public int MaxRetries { get; init; } + public required string RetryPolicy { get; init; } + public bool TrackProgress { get; init; } + public bool IsRunning { get; init; } + public long MessagesProcessed { get; init; } + public long MessagesFailed { get; init; } + public long MessagesDeadLettered { get; init; } + public long ActiveCount { get; init; } + public long DeadLetterCount { get; init; } + public long InFlightCount { get; init; } +} + +public record JobSummary +{ + public required string JobId { get; init; } + public required string QueueName { get; init; } + public required string MessageType { get; init; } + public required string Status { get; init; } + public int Progress { get; init; } + public string? ProgressMessage { get; init; } + public DateTimeOffset CreatedUtc { get; init; } + public DateTimeOffset? StartedUtc { get; init; } + public DateTimeOffset? CompletedUtc { get; init; } + public string? ErrorMessage { get; init; } +} + +public record JobCancellationResult(string JobId, bool CancellationRequested); + +public record JobDashboardView +{ + public long QueuedCount { get; init; } + public required List ActiveJobs { get; init; } + public required List RecentJobs { get; init; } +} + +public record DemoJobEnqueued(string JobId); + +// ── Demo message that gets queued with progress tracking ── + +public record DemoExportJob(int Steps = 20, int StepDelayMs = 1500); diff --git a/samples/CleanArchitectureSample/src/Common.Module/Middleware/CachingMiddleware.cs b/samples/CleanArchitectureSample/src/Common.Module/Middleware/CachingMiddleware.cs index 9371a35f..8886dabe 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Middleware/CachingMiddleware.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Middleware/CachingMiddleware.cs @@ -1,28 +1,67 @@ using System.Collections.Concurrent; using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Foundatio.Mediator; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; namespace Common.Module.Middleware; /// -/// Execute middleware that caches handler results using .NET's . +/// Execute middleware that caches handler results using .NET's . +/// Provides L1 (in-memory) + L2 (distributed via IDistributedCache/Redis) caching. /// Only applies to handlers decorated with (ExplicitOnly = true). /// Because C# records use value equality, identical query messages produce the same cache key automatically. /// +/// +/// Values are wrapped in a that preserves the concrete .NET type name. +/// This is necessary because the middleware operates on object?, and when HybridCache +/// deserializes from the L2 (Redis) cache, System.Text.Json would otherwise produce a +/// instead of the original type. +/// [Middleware(Order = 100, ExplicitOnly = true)] public class CachingMiddleware { private static readonly ConcurrentDictionary SettingsCache = new(); + + /// + /// JSON options that can deserialize types with internal/private init setters (e.g. Result<T>). + /// Without this modifier, System.Text.Json silently skips properties it cannot set, producing + /// objects with default values. + /// + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + static typeInfo => + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var prop in typeInfo.Properties) + { + if (prop.Set is null && prop.AttributeProvider is PropertyInfo pi) + { + var setter = pi.GetSetMethod(nonPublic: true); + if (setter is not null) + prop.Set = (obj, val) => setter.Invoke(obj, [val]); + } + } + } + } + } + }; + private static CachingMiddleware? _instance; - /// Tracks active cache keys so can remove them all. - private readonly ConcurrentDictionary _keys = new(); - private readonly IMemoryCache _cache; + private readonly HybridCache _cache; private readonly ILogger _logger; - public CachingMiddleware(IMemoryCache cache, ILogger logger) + public CachingMiddleware(HybridCache cache, ILogger logger) { _cache = cache; _logger = logger; @@ -33,6 +72,10 @@ public CachingMiddleware(IMemoryCache cache, ILogger logger) private static string GetCacheKey(object message) => $"mediator:{message.GetType().FullName}:{message.GetHashCode()}"; + /// Derives a tag from the message type name for group invalidation. + private static string GetTag(object message) + => $"mediator:{message.GetType().Name}"; + public async ValueTask ExecuteAsync( object message, HandlerExecutionDelegate next, @@ -49,47 +92,71 @@ private static string GetCacheKey(object message) }); var cacheKey = GetCacheKey(message); + var tag = GetTag(message); - if (_cache.TryGetValue(cacheKey, out var cached)) + var entryOptions = new HybridCacheEntryOptions { - _logger.LogDebug("CachingMiddleware: Cache HIT for {MessageType}", message.GetType().Name); - return cached; - } - - // Cache miss β€” execute the full pipeline - _logger.LogDebug("CachingMiddleware: Cache MISS for {MessageType}, executing handler", message.GetType().Name); - var result = await next(); - - var options = new MemoryCacheEntryOptions() - .RegisterPostEvictionCallback((key, _, _, _) => _keys.TryRemove((string)key, out _)); - - if (settings.SlidingExpiration) - options.SetSlidingExpiration(settings.Duration); - else - options.SetAbsoluteExpiration(settings.Duration); + Expiration = settings.Duration, + LocalCacheExpiration = settings.Duration + }; + + var executed = false; + var envelope = await _cache.GetOrCreateAsync( + cacheKey, + async ct => + { + executed = true; + _logger.LogInformation("CachingMiddleware: Cache MISS for {MessageType} (key: {CacheKey}), executing handler", message.GetType().Name, cacheKey); + var result = await next().ConfigureAwait(false); + return CacheEnvelope.Wrap(result); + }, + entryOptions, + [tag], + cancellationToken: default).ConfigureAwait(false); + + if (!executed) + _logger.LogInformation("CachingMiddleware: Cache HIT for {MessageType} (key: {CacheKey})", message.GetType().Name, cacheKey); + + return envelope.Unwrap(); + } - _cache.Set(cacheKey, result, options); - _keys.TryAdd(cacheKey, 0); + /// Removes a specific message's cached result from both L1 and L2. + public static async Task InvalidateAsync(object message) + { + if (_instance is not { } instance) + { + return; + } - return result; + var key = GetCacheKey(message); + await instance._cache.RemoveAsync(key).ConfigureAwait(false); + instance._logger.LogInformation("CachingMiddleware: Invalidated {MessageType} (key: {CacheKey})", message.GetType().Name, key); } - /// Removes a specific message's cached result. - public static void Invalidate(object message) + /// Removes all cached results for a message type from both L1 and L2 via tag. + public static async Task InvalidateByTagAsync(string tag) { - if (_instance is not { } instance) return; - var key = GetCacheKey(message); - instance._cache.Remove(key); - instance._keys.TryRemove(key, out _); + if (_instance is not { } instance) + { + return; + } + + var fullTag = $"mediator:{tag}"; + await instance._cache.RemoveByTagAsync(fullTag).ConfigureAwait(false); + instance._logger.LogInformation("CachingMiddleware: Invalidated by tag {Tag}", fullTag); } - /// Clears all mediator-cached entries. - public static void Clear() + /// Clears all mediator-cached entries from both L1 and L2. + public static async Task ClearAsync() { - if (_instance is not { } instance) return; - foreach (var key in instance._keys.Keys) - instance._cache.Remove(key); - instance._keys.Clear(); + if (_instance is not { } instance) + { + return; + } + + // The wildcard * tag invalidates all HybridCache data + await instance._cache.RemoveByTagAsync("*").ConfigureAwait(false); + instance._logger.LogInformation("CachingMiddleware: Cleared all cached entries"); } private sealed class CacheSettings @@ -97,4 +164,38 @@ private sealed class CacheSettings public TimeSpan Duration { get; init; } public bool SlidingExpiration { get; init; } } + + /// + /// Wrapper that preserves the concrete .NET type across HybridCache L2 serialization. + /// When HybridCache stores this in Redis via System.Text.Json, the + /// field lets us deserialize the back to the correct type on cache hits. + /// + private sealed class CacheEnvelope + { + public string? TypeName { get; set; } + public JsonElement? Value { get; set; } + + public static CacheEnvelope Wrap(object? value) + { + if (value is null) + return new CacheEnvelope(); + + return new CacheEnvelope + { + TypeName = value.GetType().AssemblyQualifiedName, + Value = JsonSerializer.SerializeToElement(value, value.GetType(), JsonOptions) + }; + } + + public object? Unwrap() + { + if (TypeName is null || Value is null) + return null; + + var type = Type.GetType(TypeName); + return type is not null + ? Value.Value.Deserialize(type, JsonOptions) + : null; + } + } } diff --git a/samples/CleanArchitectureSample/src/Common.Module/Middleware/ObservabilityMiddleware.cs b/samples/CleanArchitectureSample/src/Common.Module/Middleware/ObservabilityMiddleware.cs index e9f20221..f5885b2f 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Middleware/ObservabilityMiddleware.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Middleware/ObservabilityMiddleware.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Foundatio.Mediator; +using Foundatio.Mediator.Distributed; using Microsoft.Extensions.Logging; namespace Common.Module.Middleware; @@ -19,12 +20,19 @@ public class ObservabilityMiddleware { private const long SlowHandlerThresholdMs = 100; - public Stopwatch Before(object message, HandlerExecutionInfo info, ILogger logger) + public Stopwatch Before(object message, HandlerExecutionInfo info, QueueContext? queueContext, ILogger logger) { + var source = queueContext is not null + ? "queue" + : message is IDistributedNotification + ? "distributed event" + : "local"; + logger.LogInformation( - "Handling {MessageType} in {HandlerType}", + "Handling {MessageType} in {HandlerType} (source: {Source})", message.GetType().Name, - info.HandlerType.Name); + info.HandlerType.Name, + source); return Stopwatch.StartNew(); } diff --git a/samples/CleanArchitectureSample/src/Orders.Module/Data/RedisOrderRepository.cs b/samples/CleanArchitectureSample/src/Orders.Module/Data/RedisOrderRepository.cs new file mode 100644 index 00000000..0a4ac276 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Orders.Module/Data/RedisOrderRepository.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Orders.Module.Domain; +using StackExchange.Redis; + +using Order = Orders.Module.Domain.Order; + +namespace Orders.Module.Data; + +/// +/// Redis-backed implementation of . +/// Uses Redis strings for individual orders and a set to track all order IDs. +/// Shared across all API replicas so writes on one node are immediately +/// visible to reads on every other node. +/// +public class RedisOrderRepository : IOrderRepository +{ + private const string HashPrefix = "order:"; + private const string IndexKey = "orders:index"; + private const string CustomerIndexPrefix = "orders:customer:"; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private readonly IConnectionMultiplexer _redis; + + public RedisOrderRepository(IConnectionMultiplexer redis) + { + _redis = redis; + SeedIfEmptyAsync().GetAwaiter().GetResult(); + } + + private IDatabase Db => _redis.GetDatabase(); + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + var json = await Db.StringGetAsync(HashPrefix + id).ConfigureAwait(false); + return json.IsNullOrEmpty ? null : JsonSerializer.Deserialize((string)json!, JsonOptions); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + var ids = await Db.SetMembersAsync(IndexKey).ConfigureAwait(false); + if (ids.Length == 0) + return []; + + var keys = ids.Select(id => (RedisKey)(HashPrefix + (string)id!)).ToArray(); + var values = await Db.StringGetAsync(keys).ConfigureAwait(false); + + var orders = new List(values.Length); + foreach (var v in values) + { + if (!v.IsNullOrEmpty) + orders.Add(JsonSerializer.Deserialize((string)v!, JsonOptions)!); + } + + return orders; + } + + public async Task> GetByCustomerIdAsync(string customerId, CancellationToken cancellationToken = default) + { + var ids = await Db.SetMembersAsync(CustomerIndexPrefix + customerId).ConfigureAwait(false); + if (ids.Length == 0) + return []; + + var keys = ids.Select(id => (RedisKey)(HashPrefix + (string)id!)).ToArray(); + var values = await Db.StringGetAsync(keys).ConfigureAwait(false); + + var orders = new List(values.Length); + foreach (var v in values) + { + if (!v.IsNullOrEmpty) + orders.Add(JsonSerializer.Deserialize((string)v!, JsonOptions)!); + } + + return orders; + } + + public async Task AddAsync(Order order, CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(order, JsonOptions); + var batch = Db.CreateBatch(); + _ = batch.StringSetAsync(HashPrefix + order.Id, json); + _ = batch.SetAddAsync(IndexKey, order.Id); + _ = batch.SetAddAsync(CustomerIndexPrefix + order.CustomerId, order.Id); + batch.Execute(); + await Task.CompletedTask.ConfigureAwait(false); + } + + public async Task UpdateAsync(Order order, CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(order, JsonOptions); + await Db.StringSetAsync(HashPrefix + order.Id, json).ConfigureAwait(false); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) + { + // Read the order first to remove from customer index + var existing = await GetByIdAsync(id).ConfigureAwait(false); + var existed = await Db.KeyDeleteAsync(HashPrefix + id).ConfigureAwait(false); + await Db.SetRemoveAsync(IndexKey, id).ConfigureAwait(false); + if (existing is not null) + await Db.SetRemoveAsync(CustomerIndexPrefix + existing.CustomerId, id).ConfigureAwait(false); + return existed; + } + + private async Task SeedIfEmptyAsync() + { + if (await Db.SetLengthAsync(IndexKey).ConfigureAwait(false) > 0) + return; + + var baseDate = DateTime.UtcNow.AddDays(-30); + var seedOrders = new[] + { + new Order("ord-001-alice-laptop", "cust-alice", 1299.99m, + "MacBook Pro 14-inch", OrderStatus.Delivered, baseDate, baseDate.AddDays(5)), + new Order("ord-002-alice-accessories", "cust-alice", 149.99m, + "Wireless keyboard and mouse combo", OrderStatus.Delivered, baseDate.AddDays(2), baseDate.AddDays(6)), + new Order("ord-003-bob-monitor", "cust-bob", 549.99m, + "27-inch 4K Monitor", OrderStatus.Shipped, baseDate.AddDays(10), baseDate.AddDays(12)), + new Order("ord-004-charlie-headset", "cust-charlie", 299.99m, + "Premium noise-canceling headset", OrderStatus.Processing, baseDate.AddDays(20)), + new Order("ord-005-charlie-webcam", "cust-charlie", 179.99m, + "4K Webcam with ring light", OrderStatus.Confirmed, baseDate.AddDays(25)), + new Order("ord-006-diana-desk", "cust-diana", 899.99m, + "Standing desk with motorized adjustment", OrderStatus.Pending, baseDate.AddDays(28)), + new Order("ord-007-bob-cables", "cust-bob", 45.99m, + "USB-C cable bundle (5-pack)", OrderStatus.Delivered, baseDate.AddDays(15), baseDate.AddDays(18)) + }; + + foreach (var order in seedOrders) + await AddAsync(order).ConfigureAwait(false); + } +} diff --git a/samples/CleanArchitectureSample/src/Orders.Module/Handlers/OrderHandler.cs b/samples/CleanArchitectureSample/src/Orders.Module/Handlers/OrderHandler.cs index 1ea0eea8..19270191 100644 --- a/samples/CleanArchitectureSample/src/Orders.Module/Handlers/OrderHandler.cs +++ b/samples/CleanArchitectureSample/src/Orders.Module/Handlers/OrderHandler.cs @@ -15,7 +15,7 @@ namespace Orders.Module.Handlers; /// Following Clean Architecture, this handler orchestrates use cases /// and delegates persistence to the IOrderRepository abstraction. /// -[HandlerEndpointGroup("Orders", EndpointFilters = [typeof(SetRequestedByFilter)])] +[HandlerEndpointGroup(EndpointFilters = [typeof(SetRequestedByFilter)])] public class OrderHandler(IOrderRepository repository) { /// diff --git a/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj b/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj index 2d873f12..cc8529fc 100644 --- a/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj +++ b/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/samples/CleanArchitectureSample/src/Orders.Module/ServiceConfiguration.cs b/samples/CleanArchitectureSample/src/Orders.Module/ServiceConfiguration.cs index d3db9bda..f70ff591 100644 --- a/samples/CleanArchitectureSample/src/Orders.Module/ServiceConfiguration.cs +++ b/samples/CleanArchitectureSample/src/Orders.Module/ServiceConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Orders.Module.Data; using Orders.Module.Handlers; +using StackExchange.Redis; namespace Orders.Module; @@ -8,9 +9,15 @@ public static class ServiceConfiguration { public static IServiceCollection AddOrdersModule(this IServiceCollection services) { - // Register the repository - singleton for in-memory demo - // In production, you'd use Scoped with a real database - services.AddSingleton(); + // Use Redis-backed repository when IConnectionMultiplexer is registered, + // otherwise fall back to in-memory for standalone (non-Aspire) runs + services.AddSingleton(sp => + { + var redis = sp.GetService(); + return redis is not null + ? new RedisOrderRepository(redis) + : new InMemoryOrderRepository(); + }); // Register handler with scoped lifetime so it gets the repository injected services.AddScoped(); diff --git a/samples/CleanArchitectureSample/src/Products.Module/Data/RedisProductRepository.cs b/samples/CleanArchitectureSample/src/Products.Module/Data/RedisProductRepository.cs new file mode 100644 index 00000000..6ca2fb56 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Products.Module/Data/RedisProductRepository.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using Products.Module.Domain; +using StackExchange.Redis; + +namespace Products.Module.Data; + +/// +/// Redis-backed implementation of . +/// Uses Redis hashes for individual products and a set to track all product IDs. +/// Shared across all API replicas so writes on one node are immediately +/// visible to reads on every other node. +/// +public class RedisProductRepository : IProductRepository +{ + private const string HashPrefix = "product:"; + private const string IndexKey = "products:index"; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private readonly IConnectionMultiplexer _redis; + + public RedisProductRepository(IConnectionMultiplexer redis) + { + _redis = redis; + SeedIfEmptyAsync().GetAwaiter().GetResult(); + } + + private IDatabase Db => _redis.GetDatabase(); + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + var json = await Db.StringGetAsync(HashPrefix + id).ConfigureAwait(false); + return json.IsNullOrEmpty ? null : JsonSerializer.Deserialize((string)json!, JsonOptions); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + var ids = await Db.SetMembersAsync(IndexKey).ConfigureAwait(false); + if (ids.Length == 0) + return []; + + var keys = ids.Select(id => (RedisKey)(HashPrefix + (string)id!)).ToArray(); + var values = await Db.StringGetAsync(keys).ConfigureAwait(false); + + var products = new List(values.Length); + foreach (var v in values) + { + if (!v.IsNullOrEmpty) + products.Add(JsonSerializer.Deserialize((string)v!, JsonOptions)!); + } + + return products; + } + + public async Task> SearchAsync(string searchTerm, CancellationToken cancellationToken = default) + { + var all = await GetAllAsync(cancellationToken).ConfigureAwait(false); + return all.Where(p => + p.Name.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) || + p.Description.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public async Task AddAsync(Product product, CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(product, JsonOptions); + var batch = Db.CreateBatch(); + _ = batch.StringSetAsync(HashPrefix + product.Id, json); + _ = batch.SetAddAsync(IndexKey, product.Id); + batch.Execute(); + await Task.CompletedTask.ConfigureAwait(false); + } + + public async Task UpdateAsync(Product product, CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(product, JsonOptions); + await Db.StringSetAsync(HashPrefix + product.Id, json).ConfigureAwait(false); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) + { + var existed = await Db.KeyDeleteAsync(HashPrefix + id).ConfigureAwait(false); + await Db.SetRemoveAsync(IndexKey, id).ConfigureAwait(false); + return existed; + } + + private async Task SeedIfEmptyAsync() + { + // Only seed if the index is empty (first node to start) + if (await Db.SetLengthAsync(IndexKey).ConfigureAwait(false) > 0) + return; + + var baseDate = DateTime.UtcNow.AddDays(-60); + var seedProducts = new[] + { + new Product("prod-001-laptop", "MacBook Pro 14-inch", + "Apple M3 Pro chip, 18GB RAM, 512GB SSD, Space Gray", + 1299.99m, 25, ProductStatus.Active, baseDate), + new Product("prod-002-keyboard", "Wireless Ergonomic Keyboard", + "Bluetooth mechanical keyboard with backlit keys and wrist rest", + 89.99m, 150, ProductStatus.Active, baseDate.AddDays(5)), + new Product("prod-003-mouse", "Gaming Mouse Pro", + "High-precision gaming mouse with RGB lighting and 8 programmable buttons", + 59.99m, 200, ProductStatus.Active, baseDate.AddDays(5)), + new Product("prod-004-monitor", "27-inch 4K Monitor", + "Ultra HD IPS display with USB-C connectivity and built-in speakers", + 549.99m, 40, ProductStatus.Active, baseDate.AddDays(10)), + new Product("prod-005-headset", "Premium Noise-Canceling Headset", + "Wireless headset with active noise cancellation and 30-hour battery life", + 299.99m, 8, ProductStatus.Active, baseDate.AddDays(15)), + new Product("prod-006-webcam", "4K Webcam with Ring Light", + "Professional streaming webcam with built-in ring light and autofocus", + 179.99m, 75, ProductStatus.Active, baseDate.AddDays(20)), + new Product("prod-007-desk", "Standing Desk Pro", + "Motorized height-adjustable desk with memory presets and cable management", + 899.99m, 15, ProductStatus.Active, baseDate.AddDays(25)), + new Product("prod-008-cables", "USB-C Cable Bundle", + "5-pack of braided USB-C cables in various lengths (1ft, 3ft, 6ft)", + 45.99m, 500, ProductStatus.Active, baseDate.AddDays(30)), + new Product("prod-009-docking", "USB-C Docking Station", + "12-in-1 docking station with dual HDMI, ethernet, and 100W power delivery", + 189.99m, 3, ProductStatus.Active, baseDate.AddDays(35)), + new Product("prod-010-chair", "Ergonomic Office Chair", + "Mesh back office chair with lumbar support and adjustable armrests", + 449.99m, 0, ProductStatus.OutOfStock, baseDate.AddDays(40)), + new Product("prod-011-old-keyboard", "Classic Wired Keyboard", + "Basic USB keyboard - being phased out", + 19.99m, 12, ProductStatus.Discontinued, baseDate.AddDays(-100)) + }; + + foreach (var product in seedProducts) + await AddAsync(product).ConfigureAwait(false); + } +} diff --git a/samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductCacheInvalidationHandler.cs b/samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductCacheInvalidationHandler.cs new file mode 100644 index 00000000..b7ee868a --- /dev/null +++ b/samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductCacheInvalidationHandler.cs @@ -0,0 +1,44 @@ +using Common.Module.Events; +using Common.Module.Middleware; +using Microsoft.Extensions.Logging; +using Products.Module.Messages; + +namespace Products.Module.Handlers; + +/// +/// Listens for distributed product events and invalidates the local in-memory cache. +/// Because , , etc. implement +/// IDistributedNotification, they are replayed on every node via the pub/sub bus. +/// This handler ensures each node's cache stays consistent. +/// +public class ProductCacheInvalidationHandler(ILogger logger) +{ + public async Task HandleAsync(ProductCreated evt) + { + logger.LogInformation("Invalidating product caches for ProductCreated {ProductId}", evt.ProductId); + await CachingMiddleware.InvalidateAsync(new GetProducts()); + } + + public async Task HandleAsync(ProductUpdated evt) + { + logger.LogInformation("Invalidating product caches for ProductUpdated {ProductId}", evt.ProductId); + await CachingMiddleware.InvalidateAsync(new GetProducts()); + await CachingMiddleware.InvalidateAsync(new GetProduct(evt.ProductId)); + await CachingMiddleware.InvalidateAsync(new GetProductCatalog()); + } + + public async Task HandleAsync(ProductDeleted evt) + { + logger.LogInformation("Invalidating product caches for ProductDeleted {ProductId}", evt.ProductId); + await CachingMiddleware.InvalidateAsync(new GetProducts()); + await CachingMiddleware.InvalidateAsync(new GetProduct(evt.ProductId)); + await CachingMiddleware.InvalidateAsync(new GetProductCatalog()); + } + + public async Task HandleAsync(ProductStockChanged evt) + { + logger.LogInformation("Invalidating product caches for ProductStockChanged {ProductId}", evt.ProductId); + await CachingMiddleware.InvalidateAsync(new GetProduct(evt.ProductId)); + await CachingMiddleware.InvalidateAsync(new GetProductCatalog()); + } +} diff --git a/samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs b/samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs index 876cebb1..296b6a32 100644 --- a/samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs +++ b/samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs @@ -14,7 +14,6 @@ namespace Products.Module.Handlers; /// Following Clean Architecture, this handler orchestrates use cases /// and delegates persistence to the IProductRepository abstraction. /// -[HandlerEndpointGroup("Products")] public class ProductHandler(IProductRepository repository) { /// @@ -35,10 +34,8 @@ public class ProductHandler(IProductRepository repository) await repository.AddAsync(product, cancellationToken); - // Invalidate cached queries so the next list/get call returns fresh data - CachingMiddleware.Invalidate(new GetProducts()); - // Return the product and an event that will be automatically published + // Cache invalidation happens in ProductCacheInvalidationHandler, which fires on all nodes // Other modules can subscribe to ProductCreated without this module knowing about them return (product, new ProductCreated(product.Id, command.Name, command.Price, DateTime.UtcNow)); } @@ -95,12 +92,8 @@ public async Task>> HandleAsync(GetProducts query, Cancella await repository.UpdateAsync(updatedProduct, cancellationToken); - // Invalidate cached queries so the next list/get call returns fresh data - CachingMiddleware.Invalidate(new GetProducts()); - CachingMiddleware.Invalidate(new GetProduct(command.ProductId)); - CachingMiddleware.Invalidate(new GetProductCatalog()); - // Return both events - ProductUpdated always, ProductStockChanged only if stock changed + // Cache invalidation happens in ProductCacheInvalidationHandler, which fires on all nodes var updatedEvent = new ProductUpdated(command.ProductId, updatedProduct.Name, updatedProduct.Price, updatedProduct.Status.ToString(), DateTime.UtcNow); var stockEvent = stockChanged ? new ProductStockChanged(command.ProductId, existingProduct.StockQuantity, newStockQuantity, DateTime.UtcNow) @@ -120,11 +113,7 @@ public async Task>> HandleAsync(GetProducts query, Cancella if (!deleted) return (Result.NotFound($"Product {command.ProductId} not found"), null); - // Invalidate cached queries so the next list/get call returns fresh data - CachingMiddleware.Invalidate(new GetProducts()); - CachingMiddleware.Invalidate(new GetProduct(command.ProductId)); - CachingMiddleware.Invalidate(new GetProductCatalog()); - + // Cache invalidation happens in ProductCacheInvalidationHandler, which fires on all nodes return (Result.Success(), new ProductDeleted(command.ProductId, DateTime.UtcNow)); } diff --git a/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj b/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj index 33f45df3..9ee7ed14 100644 --- a/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj +++ b/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/samples/CleanArchitectureSample/src/Products.Module/ServiceConfiguration.cs b/samples/CleanArchitectureSample/src/Products.Module/ServiceConfiguration.cs index 7d40d3fa..cc59bf9a 100644 --- a/samples/CleanArchitectureSample/src/Products.Module/ServiceConfiguration.cs +++ b/samples/CleanArchitectureSample/src/Products.Module/ServiceConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Products.Module.Data; using Products.Module.Handlers; +using StackExchange.Redis; namespace Products.Module; @@ -8,9 +9,15 @@ public static class ServiceConfiguration { public static IServiceCollection AddProductsModule(this IServiceCollection services) { - // Register the repository - singleton for in-memory demo - // In production, you'd use Scoped with a real database - services.AddSingleton(); + // Use Redis-backed repository when IConnectionMultiplexer is registered, + // otherwise fall back to in-memory for standalone (non-Aspire) runs + services.AddSingleton(sp => + { + var redis = sp.GetService(); + return redis is not null + ? new RedisProductRepository(redis) + : new InMemoryProductRepository(); + }); // Register handler with scoped lifetime so it gets the repository injected services.AddScoped(); diff --git a/samples/CleanArchitectureSample/src/Reports.Module/Handlers/ReportHandler.cs b/samples/CleanArchitectureSample/src/Reports.Module/Handlers/ReportHandler.cs index fe284588..8bf055ad 100644 --- a/samples/CleanArchitectureSample/src/Reports.Module/Handlers/ReportHandler.cs +++ b/samples/CleanArchitectureSample/src/Reports.Module/Handlers/ReportHandler.cs @@ -14,7 +14,6 @@ namespace Reports.Module.Handlers; /// - All data is fetched via published queries through the mediator /// - Loose coupling enables independent module evolution /// -[HandlerEndpointGroup("Reports")] public class ReportHandler(IMediator mediator, ILogger logger) { private const int LowStockThreshold = 10; diff --git a/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs b/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..5d1c5eb2 --- /dev/null +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs @@ -0,0 +1,117 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation(o => + { + o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/api/events"); + }) + .AddHttpClientInstrumentation(o => + { + // Filter out SQS long-polling (ReceiveMessage) to reduce trace noise + o.FilterHttpRequestMessage = req => + req.Headers.TryGetValues("X-Amz-Target", out var values) != true + || !values.Any(v => v.Contains("ReceiveMessage", StringComparison.OrdinalIgnoreCase)); + }) + .AddAWSInstrumentation(o => + { + o.SuppressDownstreamInstrumentation = true; + }) + .AddSource("Foundatio.Mediator"); + + // Drop noisy SQS polling/housekeeping spans from the AWS SDK instrumentation + tracing.AddProcessor(new FilteringProcessor(activity => + activity.OperationName is not "SQS.ReceiveMessage" and not "SQS.DeleteMessage")); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } +} + +/// +/// Drops activities that don't match the predicate so they are never exported. +/// +internal sealed class FilteringProcessor(Func predicate) : BaseProcessor +{ + public override void OnEnd(Activity data) + { + if (!predicate(data)) + data.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + + base.OnEnd(data); + } +} diff --git a/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj b/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj new file mode 100644 index 00000000..aec702e0 --- /dev/null +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj @@ -0,0 +1,26 @@ + + + + Library + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + + diff --git a/samples/CleanArchitectureSample/src/Web/package-lock.json b/samples/CleanArchitectureSample/src/Web/package-lock.json index 6b9c2d9f..7e59686e 100644 --- a/samples/CleanArchitectureSample/src/Web/package-lock.json +++ b/samples/CleanArchitectureSample/src/Web/package-lock.json @@ -10,461 +10,53 @@ "devDependencies": { "@foundatiofx/fetchclient": "^1.3.3", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.53.4", - "@sveltejs/vite-plugin-svelte": "^6.2.4 || ^7.0.0", - "@tailwindcss/vite": "^4.2.1", - "@types/node": "^25.0.0", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.5.0", "clsx": "^2.1.1", - "lucide-svelte": "^0.575.0", - "svelte": "^5.53.6", - "svelte-check": "^4.4.4", + "lucide-svelte": "^1.0.1", + "svelte": "^5.55.1", + "svelte-check": "^4.4.6", "tailwind-merge": "^3.0.0", - "tailwindcss": "^4.2.1", + "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^8.0.3" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@foundatiofx/fetchclient": { @@ -524,6 +116,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -531,24 +152,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", "cpu": [ "arm64" ], @@ -557,12 +164,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", "cpu": [ "arm64" ], @@ -571,12 +181,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", "cpu": [ "x64" ], @@ -585,26 +198,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", "cpu": [ "x64" ], @@ -613,26 +215,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", "cpu": [ "arm" ], @@ -641,26 +232,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", "cpu": [ "arm64" ], @@ -669,54 +249,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", "cpu": [ "ppc64" ], @@ -725,40 +283,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", "cpu": [ "s390x" ], @@ -767,12 +300,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", "cpu": [ "x64" ], @@ -781,12 +317,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", "cpu": [ "x64" ], @@ -795,26 +334,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", "cpu": [ "arm64" ], @@ -823,40 +351,49 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", "cpu": [ "x64" ], @@ -865,21 +402,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -909,9 +442,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.53.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", - "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "dev": true, "license": "MIT", "dependencies": { @@ -920,7 +453,7 @@ "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.6.3", + "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -951,88 +484,69 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", - "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", "dev": true, "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", - "vitefu": "^1.1.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", - "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "obug": "^2.1.0" + "vitefu": "^1.1.2" }, "engines": { "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.2.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "cpu": [ "arm64" ], @@ -1047,9 +561,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "cpu": [ "arm64" ], @@ -1064,9 +578,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "cpu": [ "x64" ], @@ -1081,9 +595,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "cpu": [ "x64" ], @@ -1098,9 +612,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "cpu": [ "arm" ], @@ -1115,9 +629,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "cpu": [ "arm64" ], @@ -1132,9 +646,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "cpu": [ "arm64" ], @@ -1149,9 +663,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "cpu": [ "x64" ], @@ -1166,9 +680,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "cpu": [ "x64" ], @@ -1183,9 +697,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1213,9 +727,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ "arm64" ], @@ -1230,9 +744,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -1247,18 +761,29 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@types/cookie": { @@ -1276,9 +801,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { @@ -1292,6 +817,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1382,16 +921,16 @@ } }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "license": "MIT", "dependencies": { @@ -1402,48 +941,6 @@ "node": ">=10.13.0" } }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1452,13 +949,14 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", - "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" } }, "node_modules/fdir": { @@ -1532,9 +1030,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1548,23 +1046,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -1583,9 +1081,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -1604,9 +1102,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -1625,9 +1123,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -1646,9 +1144,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -1667,9 +1165,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -1688,9 +1186,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -1709,9 +1207,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -1730,9 +1228,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -1751,9 +1249,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -1772,9 +1270,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -1800,9 +1298,9 @@ "license": "MIT" }, "node_modules/lucide-svelte": { - "version": "0.575.0", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.575.0.tgz", - "integrity": "sha512-Tu15tJfbmRNPaU61yeNFf3jfRHs8ABA+NwTt7TWmwVbhlSA3H7sW65tX6RttcP7HGV4aHUlYhXixZOlntoFBdw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", + "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", "dev": true, "license": "ISC", "peerDependencies": { @@ -1877,9 +1375,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1890,9 +1388,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -1932,49 +1430,38 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, "node_modules/sade": { @@ -1991,9 +1478,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", - "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "dev": true, "license": "MIT" }, @@ -2023,9 +1510,9 @@ } }, "node_modules/svelte": { - "version": "5.53.6", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", - "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", + "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", "dev": true, "license": "MIT", "dependencies": { @@ -2038,9 +1525,9 @@ "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.3", + "devalue": "^5.6.4", "esm-env": "^1.2.1", - "esrap": "^2.2.2", + "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -2051,9 +1538,9 @@ } }, "node_modules/svelte-check": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.4.tgz", - "integrity": "sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2086,16 +1573,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "dev": true, "license": "MIT", "engines": { @@ -2173,17 +1660,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "bin": { @@ -2200,9 +1686,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -2215,13 +1702,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { diff --git a/samples/CleanArchitectureSample/src/Web/package.json b/samples/CleanArchitectureSample/src/Web/package.json index f84eb911..246d6a14 100644 --- a/samples/CleanArchitectureSample/src/Web/package.json +++ b/samples/CleanArchitectureSample/src/Web/package.json @@ -13,18 +13,18 @@ "devDependencies": { "@foundatiofx/fetchclient": "^1.3.3", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.53.4", - "@sveltejs/vite-plugin-svelte": "^6.2.4 || ^7.0.0", - "@tailwindcss/vite": "^4.2.1", - "@types/node": "^25.0.0", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.5.0", "clsx": "^2.1.1", - "lucide-svelte": "^0.575.0", - "svelte": "^5.53.6", - "svelte-check": "^4.4.4", + "lucide-svelte": "^1.0.1", + "svelte": "^5.55.1", + "svelte-check": "^4.4.6", "tailwind-merge": "^3.0.0", - "tailwindcss": "^4.2.1", + "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^8.0.3" } } diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/api/client.ts b/samples/CleanArchitectureSample/src/Web/src/lib/api/client.ts index 5ddb7af3..a327e310 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/api/client.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/api/client.ts @@ -1,9 +1,6 @@ import { FetchClient } from '@foundatiofx/fetchclient'; -// In development, use relative URLs so Vite's proxy handles the requests -// In production (when served by ASP.NET Core), relative URLs also work -const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; - +// Always use relative URLs so the Vite dev-server proxy (or ASP.NET Core in production) handles routing. export const api = new FetchClient({ - baseUrl + baseUrl: '' }); diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/api/index.ts b/samples/CleanArchitectureSample/src/Web/src/lib/api/index.ts index b4d345a8..fcade76f 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/api/index.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/api/index.ts @@ -4,3 +4,4 @@ export type { LoginRequest, UserInfo } from './auth'; export { ordersApi } from './orders'; export { productsApi } from './products'; export { reportsApi } from './reports'; +export { queuesApi } from './queues'; diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts b/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts new file mode 100644 index 00000000..cbc2435f --- /dev/null +++ b/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts @@ -0,0 +1,22 @@ +import { api } from './client'; +import type { QueueSummary, JobSummary, JobDashboardView, JobCancellationResult, DemoJobEnqueued } from '$lib/types/queue'; + +export const queuesApi = { + listWorkers: () => api.getJSON('/api/queues/queues'), + + getWorker: (queueName: string) => + api.getJSON(`/api/queues/queue?queueName=${encodeURIComponent(queueName)}`), + + getJobDashboard: (queueName: string) => + api.getJSON( + `/api/queues/job-dashboard?queueName=${encodeURIComponent(queueName)}` + ), + + getJob: (jobId: string) => api.getJSON(`/api/queues/queue-job/${jobId}`), + + cancelJob: (jobId: string) => + api.postJSON(`/api/queues/job/${jobId}/cancel-job`, {}), + + enqueueDemoJob: (count = 1, steps = 10, stepDelayMs = 500) => + api.postJSON('/api/queues/demo-job/enqueue-demo-job', { count, steps, stepDelayMs }) +}; diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/api/reports.ts b/samples/CleanArchitectureSample/src/Web/src/lib/api/reports.ts index f90f9048..be72f48b 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/api/reports.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/api/reports.ts @@ -7,18 +7,18 @@ import type { } from '$lib/types/report'; export const reportsApi = { - dashboard: () => api.getJSON('/api/reports'), + dashboard: () => api.getJSON('/api/reports/dashboard-report'), sales: (startDate?: string, endDate?: string) => { const params = new URLSearchParams(); if (startDate) params.set('startDate', startDate); if (endDate) params.set('endDate', endDate); const query = params.toString(); - return api.getJSON(`/api/reports/get-sales-report${query ? `?${query}` : ''}`); + return api.getJSON(`/api/reports/sales-report${query ? `?${query}` : ''}`); }, - inventory: () => api.getJSON('/api/reports/get-inventory-report'), + inventory: () => api.getJSON('/api/reports/inventory-report'), searchCatalog: (searchTerm: string) => - api.getJSON(`/api/reports/search-catalog?searchTerm=${encodeURIComponent(searchTerm)}`) + api.getJSON(`/api/reports/catalog?searchTerm=${encodeURIComponent(searchTerm)}`) }; diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/components/layout/Sidebar.svelte b/samples/CleanArchitectureSample/src/Web/src/lib/components/layout/Sidebar.svelte index 5d60a8cf..2d2a00b3 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/components/layout/Sidebar.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/lib/components/layout/Sidebar.svelte @@ -7,6 +7,7 @@ { href: '/products', label: 'Products', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' }, { href: '/reports/search', label: 'Search Catalog', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }, { href: '/events', label: 'Live Events', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, + { href: '/queues', label: 'Queues', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, ]; const adminItems = [ diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/stores/eventstream.svelte.ts b/samples/CleanArchitectureSample/src/Web/src/lib/stores/eventstream.svelte.ts index 49b43c16..ff8d80fe 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/stores/eventstream.svelte.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/stores/eventstream.svelte.ts @@ -68,7 +68,7 @@ class EventStreamService { } private connect() { - this.eventSource = new EventSource('/events/stream'); + this.eventSource = new EventSource('/api/events'); this.eventSource.addEventListener('message', (e: MessageEvent) => { try { diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/types/index.ts b/samples/CleanArchitectureSample/src/Web/src/lib/types/index.ts index e0bdeae7..45ba2e38 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/types/index.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/types/index.ts @@ -1,3 +1,4 @@ export * from './order'; export * from './product'; export * from './report'; +export * from './queue'; diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts b/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts new file mode 100644 index 00000000..3057c06f --- /dev/null +++ b/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts @@ -0,0 +1,53 @@ +export interface QueueSummary { + queueName: string; + messageType: string; + concurrency: number; + maxRetries: number; + retryPolicy: string; + trackProgress: boolean; + isRunning: boolean; + messagesProcessed: number; + messagesFailed: number; + messagesDeadLettered: number; + activeCount: number; + deadLetterCount: number; + inFlightCount: number; +} + +export type JobStatus = 'Queued' | 'Processing' | 'Completed' | 'Failed' | 'Cancelled'; + +export interface JobSummary { + jobId: string; + queueName: string; + messageType: string; + status: JobStatus; + progress: number; + progressMessage: string | null; + createdUtc: string; + startedUtc: string | null; + completedUtc: string | null; + errorMessage: string | null; +} + +export interface JobDashboardView { + queuedCount: number; + activeJobs: JobSummary[]; + recentJobs: JobSummary[]; +} + +export interface JobCancellationResult { + jobId: string; + cancellationRequested: boolean; +} + +export interface DemoJobEnqueued { + jobId: string; +} + +export const JOB_STATUS_COLORS: Record = { + Queued: 'bg-gray-100 text-gray-800', + Processing: 'bg-blue-100 text-blue-800', + Completed: 'bg-green-100 text-green-800', + Failed: 'bg-red-100 text-red-800', + Cancelled: 'bg-yellow-100 text-yellow-800' +}; diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte index 5695c460..5096f123 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte @@ -72,16 +72,12 @@ } } - // Reload orders whenever the user navigates to this page (including back from edit/create) - // Reload on SPA navigations back to this page - afterNavigate((nav) => { - if (nav.from) loadOrders(); + // Reload orders on every navigation to this page (initial load + SPA navigations back) + afterNavigate(() => { + loadOrders(); }); onMount(() => { - // Initial data load β€” afterNavigate may miss the first render when - // the layout delays mounting children (e.g. auth check) - loadOrders(); const unsubCreated = eventStream.onOrderCreated((event) => { toast.success('New order created'); diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte index cfdf7a1d..d2010697 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte @@ -72,16 +72,12 @@ } } - // Reload products whenever the user navigates to this page (including back from edit/create) - // Reload on SPA navigations back to this page - afterNavigate((nav) => { - if (nav.from) loadProducts(); + // Reload products on every navigation to this page (initial load + SPA navigations back) + afterNavigate(() => { + loadProducts(); }); onMount(() => { - // Initial data load β€” afterNavigate may miss the first render when - // the layout delays mounting children (e.g. auth check) - loadProducts(); const unsubCreated = eventStream.onProductCreated((event) => { toast.success('New product created'); diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte new file mode 100644 index 00000000..aa6f3ef9 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte @@ -0,0 +1,360 @@ + + + + Queue Dashboard - Clean Architecture Sample + + +
+ +
+
+

Queue Dashboard

+

Monitor queue workers, job progress, and manage running jobs.

+
+
+ + +
+
+ + {#if error} + {error} + {/if} + + {#if loading} +
+ +
+ {:else if workers.length === 0} +
+

No queue workers registered

+

Queue handlers will appear here when the application starts.

+
+ {:else} + +
+ + + + + + + + + + + + + + + + {#each workers as worker} + selectQueue(worker.queueName)} + > + + + + + + + + + + + {/each} + +
QueueStatusProcessedFailedQueuedIn FlightDead LetterConcurrencyTracking
+
{worker.queueName}
+
{worker.messageType}
+
+ {#if worker.isRunning} + + + + + + Running + + {:else} + + + Stopped + + {/if} + {worker.messagesProcessed.toLocaleString()}{worker.messagesFailed.toLocaleString()}{worker.activeCount.toLocaleString()}{worker.inFlightCount.toLocaleString()}{worker.deadLetterCount.toLocaleString()}{worker.concurrency} + {#if worker.trackProgress} + + {:else} + β€” + {/if} +
+
+ + + {#if selectedQueue} + {@const queueWorker = workers.find((w) => w.queueName === selectedQueue)} +
+
+
+

{selectedQueue}

+

+ Retry: {queueWorker?.retryPolicy} Β· Max retries: {queueWorker?.maxRetries} +

+
+ +
+ + {#if !queueWorker?.trackProgress} +
+

Progress tracking is not enabled for this queue.

+

Add TrackProgress = true to the [Queue] attribute.

+
+ {:else if jobsLoading && !dashboard} +
+ +
+ {:else if dashboard && (dashboard.queuedCount > 0 || dashboard.activeJobs.length > 0 || dashboard.recentJobs.length > 0)} + + {#if dashboard.queuedCount > 0} +
+ Queued + {dashboard.queuedCount.toLocaleString()} job{dashboard.queuedCount === 1 ? '' : 's'} waiting +
+ {/if} + + + {#if dashboard.activeJobs.length > 0} +
+ {#each dashboard.activeJobs as job (job.jobId)} +
+
+
+ + {job.status} + + {job.jobId.slice(0, 12)}… +
+
+ + {formatTime(job.createdUtc)} + {#if job.startedUtc} + Β· {formatDuration(job.startedUtc, job.completedUtc)} + {/if} + + +
+
+
+
+ {job.progressMessage ?? ''} + {job.progress}% +
+
+
+
+
+
+ {/each} +
+ {/if} + + + {#if dashboard.recentJobs.length > 0} + {#if dashboard.activeJobs.length > 0} +
+ {/if} +
+ {#each dashboard.recentJobs as job (job.jobId)} +
+
+
+ + {job.status} + + {job.jobId.slice(0, 12)}… +
+ + {formatTime(job.createdUtc)} + {#if job.startedUtc} + Β· {formatDuration(job.startedUtc, job.completedUtc)} + {/if} + +
+ + {#if job.status === 'Completed'} +
+
+ {job.progressMessage ?? ''} + {job.progress}% +
+
+
+
+
+ {/if} + + {#if job.errorMessage} +
+ {job.errorMessage} +
+ {/if} +
+ {/each} +
+ {/if} + {:else} +
+ No tracked jobs yet. Enqueue a message to see jobs here. +
+ {/if} +
+ {/if} + {/if} +
diff --git a/samples/CleanArchitectureSample/src/Web/vite.config.ts b/samples/CleanArchitectureSample/src/Web/vite.config.ts index 63f0c8b8..3efc4cea 100644 --- a/samples/CleanArchitectureSample/src/Web/vite.config.ts +++ b/samples/CleanArchitectureSample/src/Web/vite.config.ts @@ -6,46 +6,83 @@ import path from 'path'; import child_process from 'child_process'; import { env } from 'process'; -const baseFolder = - env.APPDATA !== undefined && env.APPDATA !== '' - ? `${env.APPDATA}/ASP.NET/https` - : `${env.HOME}/.aspnet/https`; +// When running under Aspire, WithHttpsDeveloperCertificate() handles HTTPS automatically. +// Only generate certs manually for standalone `npm run dev`. +const isAspire = !!env.PORT; +let httpsConfig: { key: Buffer; cert: Buffer } | undefined; -const certificateName = "web.frontend"; -const certFilePath = path.join(baseFolder, `${certificateName}.pem`); -const keyFilePath = path.join(baseFolder, `${certificateName}.key`); +if (!isAspire) { + const baseFolder = + env.APPDATA !== undefined && env.APPDATA !== '' + ? `${env.APPDATA}/ASP.NET/https` + : `${env.HOME}/.aspnet/https`; -if (!fs.existsSync(baseFolder)) { - fs.mkdirSync(baseFolder, { recursive: true }); + const certificateName = "web.frontend"; + const certFilePath = path.join(baseFolder, `${certificateName}.pem`); + const keyFilePath = path.join(baseFolder, `${certificateName}.key`); + + if (!fs.existsSync(baseFolder)) { + fs.mkdirSync(baseFolder, { recursive: true }); + } + + if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { + if (0 !== child_process.spawnSync('dotnet', [ + 'dev-certs', + 'https', + '--export-path', + certFilePath, + '--format', + 'Pem', + '--no-password', + ], { stdio: 'inherit', }).status) { + throw new Error("Could not create certificate."); + } + } + + httpsConfig = { + key: fs.readFileSync(keyFilePath), + cert: fs.readFileSync(certFilePath), + }; +} + +function firstDefined(values: Array): string | undefined { + return values.find(v => typeof v === 'string' && v.trim().length > 0); } -if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { - if (0 !== child_process.spawnSync('dotnet', [ - 'dev-certs', - 'https', - '--export-path', - certFilePath, - '--format', - 'Pem', - '--no-password', - ], { stdio: 'inherit', }).status) { - throw new Error("Could not create certificate."); +function getAspireApiEndpoint(): string | undefined { + const entries = Object.entries(env); + + // Aspire service discovery variables for referenced services, e.g. SERVICES__API__HTTPS__0 + const httpsEntry = entries.find(([key, value]) => + /^services__api__https__\d+$/i.test(key) && typeof value === 'string' && value.length > 0); + if (httpsEntry?.[1]) { + return httpsEntry[1]; + } + + const httpEntry = entries.find(([key, value]) => + /^services__api__http__\d+$/i.test(key) && typeof value === 'string' && value.length > 0); + if (httpEntry?.[1]) { + return httpEntry[1]; } + + return undefined; } -// Backend URL - uses ASPNETCORE_HTTPS_PORT or ASPNETCORE_URLS environment variable -const target = env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` : - env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'https://localhost:58702'; +// Backend URL for the Vite dev-server proxy (server-side only, never bundled into client code). +const target = firstDefined([ + env.API_PROXY_TARGET, + getAspireApiEndpoint(), + env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` : undefined, + env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : undefined, + 'https://localhost:5099' +]); export default defineConfig({ plugins: [tailwindcss(), sveltekit()], server: { - port: 5173, - strictPort: true, - https: { - key: fs.readFileSync(keyFilePath), - cert: fs.readFileSync(certFilePath), - }, + port: env.PORT ? Number(env.PORT) : 5173, + strictPort: false, + https: httpsConfig, proxy: { // Proxy API requests to the backend '^/api': { @@ -61,11 +98,6 @@ export default defineConfig({ '^/scalar': { target, secure: false - }, - // Proxy SSE event stream - '^/events/stream': { - target, - secure: false } } } diff --git a/samples/ConsoleSample/ConsoleSample.csproj b/samples/ConsoleSample/ConsoleSample.csproj index 799c28f3..689c7bfd 100644 --- a/samples/ConsoleSample/ConsoleSample.csproj +++ b/samples/ConsoleSample/ConsoleSample.csproj @@ -25,7 +25,8 @@ - + + diff --git a/samples/ConsoleSample/Handlers/QueueHandler.cs b/samples/ConsoleSample/Handlers/QueueHandler.cs index 3ea01665..d7d313d8 100644 --- a/samples/ConsoleSample/Handlers/QueueHandler.cs +++ b/samples/ConsoleSample/Handlers/QueueHandler.cs @@ -1,5 +1,5 @@ using ConsoleSample.Messages; -using Foundatio.Mediator.Queues; +using Foundatio.Mediator.Distributed; using Microsoft.Extensions.Logging; namespace ConsoleSample.Handlers; diff --git a/samples/ConsoleSample/Program.cs b/samples/ConsoleSample/Program.cs index 5cd3b3b7..03fca857 100644 --- a/samples/ConsoleSample/Program.cs +++ b/samples/ConsoleSample/Program.cs @@ -6,8 +6,11 @@ // Create application host var builder = Host.CreateApplicationBuilder(args); +// Check if --sqs flag is passed +var useSqs = args.Contains("--sqs", StringComparer.OrdinalIgnoreCase); + // Configure all services -builder.Services.ConfigureServices(); +builder.Services.ConfigureServices(useSqs); var host = builder.Build(); diff --git a/samples/ConsoleSample/SampleRunner.cs b/samples/ConsoleSample/SampleRunner.cs index b44fe699..4333fd91 100644 --- a/samples/ConsoleSample/SampleRunner.cs +++ b/samples/ConsoleSample/SampleRunner.cs @@ -132,17 +132,17 @@ private async Task RunEventPublishingExamples() private async Task RunQueueExample() { - Console.WriteLine("5️⃣ Queue Processing (via SlimMessageBus)"); + Console.WriteLine("5️⃣ Queue Processing (Distributed)"); Console.WriteLine("==========================================\n"); Console.WriteLine("πŸ“¨ Enqueuing report generation (will be processed asynchronously)...\n"); - // This returns immediately β€” the message is published to the bus + // This returns immediately β€” the message is serialized and sent to the queue await _mediator.InvokeAsync(new GenerateReport("Monthly Sales Report", 5)); - // Wait for the bus consumer to process the message - Console.WriteLine("⏳ Waiting for consumer to process...\n"); + // Wait for the queue worker to process the message + Console.WriteLine("⏳ Waiting for worker to process...\n"); await Task.Delay(2000); Console.WriteLine("βœ… Queue processing completed"); diff --git a/samples/ConsoleSample/ServiceConfiguration.cs b/samples/ConsoleSample/ServiceConfiguration.cs index d8f83cd6..1cd57343 100644 --- a/samples/ConsoleSample/ServiceConfiguration.cs +++ b/samples/ConsoleSample/ServiceConfiguration.cs @@ -1,5 +1,8 @@ +using Amazon.Runtime; +using Amazon.SQS; using Foundatio.Mediator; -using Foundatio.Mediator.Queues; +using Foundatio.Mediator.Distributed; +using Foundatio.Mediator.Distributed.Aws; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,7 +10,7 @@ namespace ConsoleSample; public static class ServiceConfiguration { - public static IServiceCollection ConfigureServices(this IServiceCollection services) + public static IServiceCollection ConfigureServices(this IServiceCollection services, bool useSqs = false) { // Add logging services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information)); @@ -15,8 +18,24 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi // Add Foundatio Mediator services.AddMediator(); - // Add queue support (discovers [Queue]-decorated handlers and starts background workers) - services.AddMediatorQueues(); + if (useSqs) + { + // Register the SQS client pointing at LocalStack with dummy credentials + services.AddSingleton(new AmazonSQSClient( + new BasicAWSCredentials("test", "test"), + new AmazonSQSConfig + { + ServiceURL = "http://localhost:4566", + AuthenticationRegion = "us-east-1" + })); + + // Use SQS as the queue transport + services.AddMediatorSqs(); + } + + // Add distributed queue support (discovers [Queue]-decorated handlers and starts background workers) + // Falls back to in-memory if no IQueueClient was registered above + services.AddMediatorDistributed(); return services; } diff --git a/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs b/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs index fe97786b..cf65c553 100644 --- a/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs +++ b/src/Foundatio.Mediator.Abstractions/HandlerRegistration.cs @@ -68,7 +68,7 @@ private static PublishAsyncDelegate CreatePublishDelegate(HandleAsyncDelegate ha { return (mediator, msg, cancellationToken) => { - var task = handleAsync(mediator, msg, null, cancellationToken, null); + var task = handleAsync(mediator, msg, null, cancellationToken, null, skipAuthorization: true); if (task.IsCompletedSuccessfully) return default; return AwaitAndDiscard(task); @@ -289,10 +289,10 @@ public IReadOnlyList GetAttributes() where /// Delegate type for asynchronous handler dispatch. Used by source-generated handler wrappers. /// [EditorBrowsable(EditorBrowsableState.Never)] -public delegate ValueTask HandleAsyncDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType); +public delegate ValueTask HandleAsyncDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType, bool skipAuthorization = false); /// /// Delegate type for synchronous handler dispatch. Used by source-generated handler wrappers. /// [EditorBrowsable(EditorBrowsableState.Never)] -public delegate object? HandleDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType); +public delegate object? HandleDelegate(IMediator mediator, object message, CallContext? callContext, CancellationToken cancellationToken, Type? returnType, bool skipAuthorization = false); diff --git a/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs b/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs index 0ada014c..1762810c 100644 --- a/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs +++ b/src/Foundatio.Mediator.Abstractions/HandlerRegistry.cs @@ -34,6 +34,45 @@ public sealed class HandlerRegistry : IDisposable private readonly ConcurrentDictionary _messageTypeMatchCache = new(); private readonly object _subscriptionWriteLock = new(); private volatile bool _disposed; + private volatile bool _startupLogged; + + /// + /// Gets or sets whether to log all registered handlers at startup. + /// Set during AddMediator; consumed on first call. + /// + internal bool LogHandlersAtStartup { get; set; } + + /// + /// Gets or sets whether to log the middleware pipeline at startup. + /// Set during AddMediator; consumed on first call. + /// + internal bool LogMiddlewareAtStartup { get; set; } + + /// + /// Logs startup information (handler/middleware registrations) using the provided logger. + /// Called once from the constructor so logging goes through MS logging. + /// Short-circuits on a volatile bool read, so subsequent calls are essentially free. + /// + internal void TryLogStartupInfo(IServiceProvider serviceProvider) + { + if (_startupLogged) + return; + _startupLogged = true; + + var loggerFactory = serviceProvider.GetService(typeof(ILoggerFactory)) as ILoggerFactory; + var logger = loggerFactory?.CreateLogger("Foundatio.Mediator"); + if (logger == null) + return; + + if (LogHandlersAtStartup) + ShowRegisteredHandlers(logger); + + if (LogMiddlewareAtStartup) + ShowRegisteredMiddleware(logger); + + if (!LogHandlersAtStartup && !LogMiddlewareAtStartup) + logger.LogInformation("Foundatio.Mediator registered {HandlerCount} handler(s) and {MiddlewareCount} middleware.", _allRegistrations.Count, _allMiddleware.Count); + } /// /// Adds a handler registration to the registry. Must be called before . @@ -458,9 +497,9 @@ private PublishAsyncDelegate[] BuildAndCachePublishHandlers(Type messageType) if (asyncMethod == null) return null; - HandleAsyncDelegate asyncDelegate = (mediator, message, callContext, ct, returnType) => + HandleAsyncDelegate asyncDelegate = (mediator, message, callContext, ct, returnType, skipAuthorization) => { - object? taskObj = asyncMethod.Invoke(null, [mediator, message, callContext, ct, returnType]); + object? taskObj = asyncMethod.Invoke(null, [mediator, message, callContext, ct, returnType, skipAuthorization]); return taskObj is ValueTask vt ? vt : (ValueTask)taskObj!; }; @@ -470,7 +509,7 @@ private PublishAsyncDelegate[] BuildAndCachePublishHandlers(Type messageType) var syncMethod = wrapperClosed.GetMethod("UntypedHandle", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (syncMethod != null) { - syncDelegate = (mediator, message, callContext, ct, returnType) => syncMethod.Invoke(null, [mediator, message, callContext, ct, returnType]); + syncDelegate = (mediator, message, callContext, ct, returnType, skipAuthorization) => syncMethod.Invoke(null, [mediator, message, callContext, ct, returnType, skipAuthorization]); } } @@ -504,6 +543,7 @@ public bool HasSubscribers /// /// /// The notification type to subscribe to. Can be a concrete type, base class, or interface. + /// Use to also receive publisher metadata. /// Messages are matched using . /// /// Token that ends the subscription when cancelled. @@ -524,11 +564,10 @@ public async IAsyncEnumerable SubscribeAsync( SingleReader = true }); - var entry = new SubscriptionEntry( - msg => channel.Writer.TryWrite((T)msg), - () => channel.Writer.TryComplete()); + // Detect if T is MessageContext β€” if so, subscribe to TInner but wrap into the context. + var (subscriptionType, entry) = CreateSubscriptionEntry(channel.Writer); - AddSubscription(typeof(T), entry); + AddSubscription(subscriptionType, entry); try { // Use WaitToReadAsync + TryRead instead of ReadAllAsync so we can @@ -557,11 +596,57 @@ public async IAsyncEnumerable SubscribeAsync( } finally { - RemoveSubscription(typeof(T), entry); + RemoveSubscription(subscriptionType, entry); channel.Writer.TryComplete(); } } + /// + /// Creates a appropriate for the channel type. + /// When is , the entry + /// subscribes to the inner message type and wraps writes with context. + /// Otherwise, it subscribes to directly and discards context. + /// + private static (Type subscriptionType, SubscriptionEntry entry) CreateSubscriptionEntry(ChannelWriter writer) + { + // Check if T is MessageContext + if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(MessageContext<>)) + { + // Use a generic helper to avoid Activator.CreateInstance on every write + var helperType = typeof(MessageContextSubscriptionHelper<>).MakeGenericType(typeof(T).GetGenericArguments()[0]); + var helper = (ISubscriptionEntryFactory)Activator.CreateInstance(helperType)!; + return helper.Create(writer); + } + else + { + var entry = new SubscriptionEntry( + (msg, _) => writer.TryWrite((T)msg), + () => writer.TryComplete()); + return (typeof(T), entry); + } + } + + private interface ISubscriptionEntryFactory + { + (Type subscriptionType, SubscriptionEntry entry) Create(ChannelWriter writer); + } + + /// + /// Generic helper that creates a for + /// subscriptions. Created once per subscription via reflection; the write delegate itself is a + /// direct generic call with no reflection per message. + /// + private sealed class MessageContextSubscriptionHelper : ISubscriptionEntryFactory> + { + public (Type subscriptionType, SubscriptionEntry entry) Create(ChannelWriter> writer) + { + var entry = new SubscriptionEntry( + (msg, ctx) => writer.TryWrite(new MessageContext((TInner)msg, ctx)), + () => writer.TryComplete()); + return (typeof(TInner), entry); + } + } + /// /// Fans out a published message to all active dynamic subscribers whose type filter matches. /// Non-blocking: never awaits. @@ -575,6 +660,8 @@ public void TryWriteSubscription(object message) if (groups.Count == 0) return; + // Capture the current activity context so it travels with the message through the channel. + var context = Activity.Current?.Context ?? default; var messageType = message.GetType(); // One-time IsAssignableFrom check per unique message type; cached thereafter. @@ -595,7 +682,7 @@ public void TryWriteSubscription(object message) if (groups.TryGetValue(matchingTypes[i], out var entries)) { for (int j = 0; j < entries.Length; j++) - entries[j].Write(message); + entries[j].Write(message, context); } } } @@ -657,9 +744,9 @@ private void RemoveSubscription(Type subscriptionType, SubscriptionEntry entry) } } - private sealed class SubscriptionEntry(Action write, Action complete) + private sealed class SubscriptionEntry(Action write, Action complete) { - public void Write(object message) => write(message); + public void Write(object message, ActivityContext context) => write(message, context); public void Complete() => complete(); } diff --git a/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj b/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj new file mode 100644 index 00000000..f950ddea --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj @@ -0,0 +1,17 @@ + + + + + net10.0 + latest + enable + Foundatio.Mediator.Distributed.Aws + + + + + + + + + diff --git a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs new file mode 100644 index 00000000..af74ad48 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs @@ -0,0 +1,344 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// implementation using SNS for fan-out publishing and +/// per-node SQS queues for subscription. Each subscriber creates a dedicated SQS queue +/// subscribed to the SNS topic, enabling true pub/sub fan-out across nodes. +/// +public sealed class SnsSqsPubSubClient : IPubSubClient, IAsyncDisposable +{ + private readonly IAmazonSimpleNotificationService _sns; + private readonly IAmazonSQS _sqs; + private readonly SnsSqsPubSubClientOptions _options; + private readonly string _hostId; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _topicArnCache = new(); + private readonly ConcurrentDictionary _subscriptionSetupCache = new(); + private readonly ConcurrentBag _activeSubscriptions = []; + + public SnsSqsPubSubClient( + IAmazonSimpleNotificationService sns, + IAmazonSQS sqs, + SnsSqsPubSubClientOptions options, + DistributedNotificationOptions notificationOptions, + ILogger logger) + { + _sns = sns; + _sqs = sqs; + _options = options; + _hostId = notificationOptions.HostId; + _logger = logger; + } + + /// + public async Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default) + { + var topicArn = await GetOrCreateTopicArnAsync(topic, cancellationToken).ConfigureAwait(false); + + // Wrap body + headers into a single JSON envelope for SNS + var envelope = new MessageEnvelope + { + Body = Convert.ToBase64String(body.Span), + Headers = headers is not null ? new Dictionary(headers) : null + }; + + var json = JsonSerializer.Serialize(envelope); + + await _sns.PublishAsync(new PublishRequest + { + TopicArn = topicArn, + Message = json + }, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SubscribeAsync(string topic, Func handler, CancellationToken cancellationToken = default) + { + var setup = await EnsureSubscriptionSetupAsync(topic, cancellationToken).ConfigureAwait(false); + + // Start polling the SQS queue + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var pollTask = Task.Run(async () => + { + await PollQueueAsync(setup.QueueUrl, handler, cts.Token).ConfigureAwait(false); + }, cts.Token); + + var handle = new SubscriptionHandle( + setup.QueueUrl, setup.QueueName, setup.SubscriptionArn, setup.TopicArn, cts, pollTask, + _sqs, _sns, _options, _logger); + + _activeSubscriptions.Add(handle); + + return handle; + } + + /// + /// Ensures per-node subscription infrastructure is created for a topic. + /// Creates the SNS topic, a per-node SQS queue, sets the queue policy, + /// and subscribes the queue to the topic. Results are cached so subsequent + /// calls (including from ) make no API calls. + /// + private async Task EnsureSubscriptionSetupAsync(string topic, CancellationToken cancellationToken) + { + if (_subscriptionSetupCache.TryGetValue(topic, out var cached)) + return cached; + + var topicArn = await GetOrCreateTopicArnAsync(topic, cancellationToken).ConfigureAwait(false); + + // Create a per-node SQS queue + var queueName = $"{_options.QueuePrefix}-{_hostId}"; + var createResponse = await _sqs.CreateQueueAsync(new CreateQueueRequest + { + QueueName = queueName + }, cancellationToken).ConfigureAwait(false); + var queueUrl = createResponse.QueueUrl; + + // Get queue ARN for the subscription policy + var queueAttrs = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest + { + QueueUrl = queueUrl, + AttributeNames = ["QueueArn"] + }, cancellationToken).ConfigureAwait(false); + var queueArn = queueAttrs.QueueARN; + + // Set the SQS queue policy to allow SNS to send messages + var policy = $$""" + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "sns.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": "{{queueArn}}", + "Condition": { + "ArnEquals": { "aws:SourceArn": "{{topicArn}}" } + } + }] + } + """; + + await _sqs.SetQueueAttributesAsync(new SetQueueAttributesRequest + { + QueueUrl = queueUrl, + Attributes = new Dictionary + { + ["Policy"] = policy + } + }, cancellationToken).ConfigureAwait(false); + + // Subscribe the SQS queue to the SNS topic + var subscribeResponse = await _sns.SubscribeAsync(new SubscribeRequest + { + TopicArn = topicArn, + Protocol = "sqs", + Endpoint = queueArn, + Attributes = new Dictionary + { + // Enable raw message delivery so we get the message directly without SNS wrapper + ["RawMessageDelivery"] = "true" + } + }, cancellationToken).ConfigureAwait(false); + var subscriptionArn = subscribeResponse.SubscriptionArn; + + _logger.LogInformation( + "Subscribed to SNS topic {TopicArn} via SQS queue {QueueName} (subscription={SubscriptionArn})", + topicArn, queueName, subscriptionArn); + + var setup = new SubscriptionSetup(topicArn, queueName, queueUrl, subscriptionArn); + _subscriptionSetupCache[topic] = setup; + return setup; + } + + private async Task PollQueueAsync(string queueUrl, Func handler, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + var response = await _sqs.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = queueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = _options.WaitTimeSeconds + }, cancellationToken).ConfigureAwait(false); + + if (response.Messages is not { Count: > 0 }) + continue; + + foreach (var sqsMessage in response.Messages) + { + try + { + var envelope = JsonSerializer.Deserialize(sqsMessage.Body); + if (envelope is null) + continue; + + var body = Convert.FromBase64String(envelope.Body); + var headers = envelope.Headers is not null + ? new Dictionary(envelope.Headers) + : new Dictionary(); + + var message = new PubSubMessage + { + Body = body, + Headers = headers + }; + + await handler(message, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing bus message from SQS queue"); + } + + // Delete processed message + try + { + await _sqs.DeleteMessageAsync(new DeleteMessageRequest + { + QueueUrl = queueUrl, + ReceiptHandle = sqsMessage.ReceiptHandle + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete processed message from SQS queue"); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error polling SQS queue, retrying..."); + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { break; } + } + } + } + + private async Task GetOrCreateTopicArnAsync(string topic, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(_options.TopicArn)) + return _options.TopicArn; + + if (_topicArnCache.TryGetValue(topic, out var cached)) + return cached; + + if (_options.AutoCreate) + { + var response = await _sns.CreateTopicAsync(new CreateTopicRequest + { + Name = topic + }, cancellationToken).ConfigureAwait(false); + + _topicArnCache[topic] = response.TopicArn; + return response.TopicArn; + } + + // Find existing topic + var findResponse = await _sns.FindTopicAsync(topic).ConfigureAwait(false); + if (findResponse?.TopicArn is null) + throw new InvalidOperationException($"SNS topic '{topic}' not found and AutoCreate is disabled."); + + _topicArnCache[topic] = findResponse.TopicArn; + return findResponse.TopicArn; + } + + /// + public Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) + { + // Pre-create topics and per-node subscription infrastructure so + // SubscribeAsync later makes zero API calls (all results are cached). + return Task.WhenAll(topics.Select(topic => EnsureSubscriptionSetupAsync(topic, cancellationToken))); + } + + private record SubscriptionSetup(string TopicArn, string QueueName, string QueueUrl, string SubscriptionArn); + + public async ValueTask DisposeAsync() + { + foreach (var handle in _activeSubscriptions) + await handle.DisposeAsync().ConfigureAwait(false); + } + + private sealed class MessageEnvelope + { + public string Body { get; set; } = string.Empty; + public Dictionary? Headers { get; set; } + } + + private sealed class SubscriptionHandle( + string queueUrl, + string queueName, + string subscriptionArn, + string topicArn, + CancellationTokenSource cts, + Task pollTask, + IAmazonSQS sqs, + IAmazonSimpleNotificationService sns, + SnsSqsPubSubClientOptions options, + ILogger logger) : IAsyncDisposable + { + private int _disposed; + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + // Stop polling + await cts.CancelAsync().ConfigureAwait(false); + try { await pollTask.ConfigureAwait(false); } catch (OperationCanceledException) { } + cts.Dispose(); + + if (!options.CleanupOnDispose) + return; + + // Unsubscribe from SNS + try + { + await sns.UnsubscribeAsync(new UnsubscribeRequest + { + SubscriptionArn = subscriptionArn + }).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to unsubscribe {SubscriptionArn} from SNS topic {TopicArn}", + subscriptionArn, topicArn); + } + + // Delete per-node SQS queue + try + { + await sqs.DeleteQueueAsync(new DeleteQueueRequest + { + QueueUrl = queueUrl + }).ConfigureAwait(false); + + logger.LogInformation("Deleted per-node SQS queue {QueueName}", queueName); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete per-node SQS queue {QueueName}", queueName); + } + } + } +} diff --git a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClientOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClientOptions.cs new file mode 100644 index 00000000..dff79a14 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClientOptions.cs @@ -0,0 +1,43 @@ +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// Options for configuring the SNS+SQS pub/sub client. +/// +public class SnsSqsPubSubClientOptions +{ + /// + /// The SNS topic name. This is used to create or look up the topic. + /// When is set, this is ignored. + /// Default is "distributed-notifications". + /// + public string TopicName { get; set; } = "distributed-notifications"; + + /// + /// When set, the topic ARN is used directly instead of creating/looking up by name. + /// + public string? TopicArn { get; set; } + + /// + /// When true, the SNS topic and per-node SQS queue are automatically created if they + /// do not exist. Default is true. Disable in production where infrastructure is + /// provisioned via IaC. + /// + public bool AutoCreate { get; set; } = true; + + /// + /// Prefix for the per-node SQS queue name. The queue is named + /// {QueuePrefix}-{HostId}. Default is "notifications". + /// + public string QueuePrefix { get; set; } = "notifications"; + + /// + /// SQS long-poll wait time in seconds. Default is 20 (maximum). + /// + public int WaitTimeSeconds { get; set; } = 20; + + /// + /// When true, the per-node SQS queue and SNS subscription are deleted on dispose. + /// Default is true. + /// + public bool CleanupOnDispose { get; set; } = true; +} diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs new file mode 100644 index 00000000..74781fe6 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs @@ -0,0 +1,315 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Amazon.SQS; +using Amazon.SQS.Model; + +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// implementation backed by Amazon SQS. +/// Headers are mapped to SQS MessageAttributes. Body is sent as the MessageBody string +/// (base64-encoded from the raw bytes). +/// +public sealed class SqsQueueClient : IQueueClient +{ + private readonly IAmazonSQS _sqs; + private readonly SqsQueueClientOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _queueUrlCache = new(); + + public SqsQueueClient(IAmazonSQS sqs, SqsQueueClientOptions? options = null, TimeProvider? timeProvider = null) + { + _sqs = sqs; + _options = options ?? new SqsQueueClientOptions(); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default) + { + var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); + + var request = new SendMessageRequest + { + QueueUrl = queueUrl, + MessageBody = Convert.ToBase64String(entry.Body.Span), + MessageAttributes = new Dictionary() + }; + + if (entry.Headers is { Count: > 0 }) + { + foreach (var (key, value) in entry.Headers) + { + request.MessageAttributes[key] = new MessageAttributeValue + { + DataType = "String", + StringValue = value + }; + } + } + + await _sqs.SendMessageAsync(request, cancellationToken).ConfigureAwait(false); + } + + public async Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) + { + if (entries.Count == 0) + return; + + var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); + + // SQS batch limit is 10 messages + for (int i = 0; i < entries.Count; i += 10) + { + var batch = new SendMessageBatchRequest + { + QueueUrl = queueUrl, + Entries = [] + }; + + var end = Math.Min(i + 10, entries.Count); + for (int j = i; j < end; j++) + { + var entry = entries[j]; + var batchEntry = new SendMessageBatchRequestEntry + { + Id = j.ToString(CultureInfo.InvariantCulture), + MessageBody = Convert.ToBase64String(entry.Body.Span), + MessageAttributes = new Dictionary() + }; + + if (entry.Headers is { Count: > 0 }) + { + foreach (var (key, value) in entry.Headers) + { + batchEntry.MessageAttributes[key] = new MessageAttributeValue + { + DataType = "String", + StringValue = value + }; + } + } + + batch.Entries.Add(batchEntry); + } + + var response = await _sqs.SendMessageBatchAsync(batch, cancellationToken).ConfigureAwait(false); + + if (response.Failed is { Count: > 0 }) + { + var first = response.Failed[0]; + throw new InvalidOperationException( + $"Failed to send {response.Failed.Count} message(s) to SQS queue '{queueName}': [{first.Code}] {first.Message}"); + } + } + } + + public async Task> ReceiveAsync(string queueName, int maxCount, CancellationToken cancellationToken = default) + { + var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); + + // SQS maximum is 10 messages per receive + var request = new ReceiveMessageRequest + { + QueueUrl = queueUrl, + MaxNumberOfMessages = Math.Min(maxCount, 10), + WaitTimeSeconds = _options.WaitTimeSeconds, + MessageSystemAttributeNames = ["ApproximateReceiveCount", "SentTimestamp"], + MessageAttributeNames = ["All"] + }; + + var response = await _sqs.ReceiveMessageAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.Messages is not { Count: > 0 }) + return []; + + var now = _timeProvider.GetUtcNow(); + var results = new List(response.Messages.Count); + + foreach (var sqsMessage in response.Messages) + { + var headers = new Dictionary(); + if (sqsMessage.MessageAttributes is { Count: > 0 }) + { + foreach (var (key, attr) in sqsMessage.MessageAttributes) + headers[key] = attr.StringValue; + } + + int dequeueCount = 1; + if (sqsMessage.Attributes?.TryGetValue("ApproximateReceiveCount", out var receiveCountStr) == true + && int.TryParse(receiveCountStr, out var parsed)) + dequeueCount = parsed; + + var enqueuedAt = now; + if (sqsMessage.Attributes?.TryGetValue("SentTimestamp", out var sentTimestampStr) == true + && long.TryParse(sentTimestampStr, out var epochMs)) + enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(epochMs); + + results.Add(new QueueMessage + { + Id = sqsMessage.MessageId, + Body = Convert.FromBase64String(sqsMessage.Body), + Headers = headers, + QueueName = queueName, + DequeueCount = dequeueCount, + EnqueuedAt = enqueuedAt, + DequeuedAt = now, + NativeMessage = sqsMessage // Carry the full SQS message for ReceiptHandle access + }); + } + + return results; + } + + public async Task CompleteAsync(QueueMessage message, CancellationToken cancellationToken = default) + { + var queueUrl = await GetQueueUrlAsync(message.QueueName, cancellationToken).ConfigureAwait(false); + var sqsMessage = GetNativeMessage(message); + + await _sqs.DeleteMessageAsync(new DeleteMessageRequest + { + QueueUrl = queueUrl, + ReceiptHandle = sqsMessage.ReceiptHandle + }, cancellationToken).ConfigureAwait(false); + } + + public async Task AbandonAsync(QueueMessage message, TimeSpan delay = default, CancellationToken cancellationToken = default) + { + var queueUrl = await GetQueueUrlAsync(message.QueueName, cancellationToken).ConfigureAwait(false); + var sqsMessage = GetNativeMessage(message); + + var visibilityTimeout = Math.Max(0, (int)Math.Ceiling(delay.TotalSeconds)); + await _sqs.ChangeMessageVisibilityAsync(new ChangeMessageVisibilityRequest + { + QueueUrl = queueUrl, + ReceiptHandle = sqsMessage.ReceiptHandle, + VisibilityTimeout = visibilityTimeout + }, cancellationToken).ConfigureAwait(false); + } + + public async Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default) + { + var dlqName = $"{message.QueueName}-dead-letter"; + + // Build a new entry with original body + headers + dead-letter metadata + var headers = new Dictionary(message.Headers) + { + [MessageHeaders.DeadLetterReason] = reason, + [MessageHeaders.DeadLetteredAt] = _timeProvider.GetUtcNow().ToString("O"), + [MessageHeaders.OriginalQueueName] = message.QueueName, + [MessageHeaders.DeadLetterDequeueCount] = message.DequeueCount.ToString() + }; + + var entry = new QueueEntry + { + Body = message.Body, + Headers = headers + }; + + // Send to DLQ then complete the original message + await SendAsync(dlqName, entry, cancellationToken).ConfigureAwait(false); + await CompleteAsync(message, cancellationToken).ConfigureAwait(false); + } + + public async Task RenewTimeoutAsync(QueueMessage message, TimeSpan extension, CancellationToken cancellationToken = default) + { + var queueUrl = await GetQueueUrlAsync(message.QueueName, cancellationToken).ConfigureAwait(false); + var sqsMessage = GetNativeMessage(message); + + await _sqs.ChangeMessageVisibilityAsync(new ChangeMessageVisibilityRequest + { + QueueUrl = queueUrl, + ReceiptHandle = sqsMessage.ReceiptHandle, + VisibilityTimeout = (int)Math.Ceiling(extension.TotalSeconds) + }, cancellationToken).ConfigureAwait(false); + } + + private async Task GetQueueUrlAsync(string queueName, CancellationToken cancellationToken) + { + if (_queueUrlCache.TryGetValue(queueName, out var cached)) + return cached; + + if (_options.AutoCreateQueues) + { + var createResponse = await _sqs.CreateQueueAsync(new CreateQueueRequest + { + QueueName = queueName + }, cancellationToken).ConfigureAwait(false); + + _queueUrlCache[queueName] = createResponse.QueueUrl; + return createResponse.QueueUrl; + } + + var response = await _sqs.GetQueueUrlAsync(new GetQueueUrlRequest + { + QueueName = queueName + }, cancellationToken).ConfigureAwait(false); + + _queueUrlCache[queueName] = response.QueueUrl; + return response.QueueUrl; + } + + /// + public Task EnsureQueuesAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + { + return Task.WhenAll(queueNames.Select(name => GetQueueUrlAsync(name, cancellationToken))); + } + + /// + public async Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) + { + var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); + + var response = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest + { + QueueUrl = queueUrl, + AttributeNames = ["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"] + }, cancellationToken).ConfigureAwait(false); + + long activeCount = 0; + if (response.Attributes.TryGetValue("ApproximateNumberOfMessages", out var activeStr) + && long.TryParse(activeStr, out var parsedActive)) + activeCount = parsedActive; + + long inFlightCount = 0; + if (response.Attributes.TryGetValue("ApproximateNumberOfMessagesNotVisible", out var inFlightStr) + && long.TryParse(inFlightStr, out var parsedInFlight)) + inFlightCount = parsedInFlight; + + // Try to get dead-letter queue stats + long deadLetterCount = 0; + var dlqName = $"{queueName}-dead-letter"; + if (_queueUrlCache.ContainsKey(dlqName)) + { + try + { + var dlqUrl = await GetQueueUrlAsync(dlqName, cancellationToken).ConfigureAwait(false); + var dlqResponse = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest + { + QueueUrl = dlqUrl, + AttributeNames = ["ApproximateNumberOfMessages"] + }, cancellationToken).ConfigureAwait(false); + + if (dlqResponse.Attributes.TryGetValue("ApproximateNumberOfMessages", out var dlqStr) + && long.TryParse(dlqStr, out var parsedDlq)) + deadLetterCount = parsedDlq; + } + catch + { + // DLQ may not exist yet + } + } + + return new QueueStats + { + QueueName = queueName, + ActiveCount = activeCount, + InFlightCount = inFlightCount, + DeadLetterCount = deadLetterCount + }; + } + + private static Message GetNativeMessage(QueueMessage message) + => message.NativeMessage as Message + ?? throw new InvalidOperationException( + "QueueMessage.NativeMessage is not an SQS Message. This QueueMessage was not created by SqsQueueClient."); +} diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs new file mode 100644 index 00000000..82965e11 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs @@ -0,0 +1,26 @@ +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// Options for configuring the SQS queue client. +/// +public class SqsQueueClientOptions +{ + /// + /// When true, queues are automatically created if they do not exist. + /// Default is true (convenient for dev/test). Disable in production where + /// queues are provisioned via IaC. + /// + public bool AutoCreateQueues { get; set; } = true; + + /// + /// SQS long-poll wait time in seconds. Default is 20 (maximum). + /// Set to 0 for short polling. + /// + public int WaitTimeSeconds { get; set; } = 20; + + /// + /// Default visibility timeout in seconds for received messages. Default is 30. + /// Can be overridden per-queue via . + /// + public int DefaultVisibilityTimeoutSeconds { get; set; } = 30; +} diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs new file mode 100644 index 00000000..50d19e3d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs @@ -0,0 +1,82 @@ +using Amazon.SimpleNotificationService; +using Amazon.SQS; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// Extension methods for registering the SQS transport for Foundatio.Mediator.Distributed. +/// +public static class SqsServiceExtensions +{ + /// + /// Registers as the implementation. + /// Must be called before AddMediatorDistributed() so that the SQS client + /// is already registered when the middleware and workers are wired up. + /// + /// The service collection. + /// Optional configuration for . + /// The service collection for chaining. + /// + /// + /// // LocalStack / dev + /// services.AddSingleton<IAmazonSQS>(new AmazonSQSClient( + /// new AmazonSQSConfig { ServiceURL = "http://localhost:4566" })); + /// services.AddMediatorSqs(); + /// services.AddMediatorDistributed(); + /// + /// // Production (uses default credential chain) + /// services.AddAWSService<IAmazonSQS>(); + /// services.AddMediatorSqs(opts => opts.AutoCreateQueues = false); + /// services.AddMediatorDistributed(); + /// + /// + public static IServiceCollection AddMediatorSqs( + this IServiceCollection services, + Action? configure = null) + { + var options = new SqsQueueClientOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(); + + return services; + } + + /// + /// Registers as the implementation. + /// Must be called before AddMediatorDistributedNotifications() so that the + /// bus is already registered. Requires and + /// in DI. + /// + /// The service collection. + /// Optional configuration for . + /// The service collection for chaining. + /// + /// + /// services.AddSingleton<IAmazonSQS>(new AmazonSQSClient(...)); + /// services.AddSingleton<IAmazonSimpleNotificationService>(new AmazonSimpleNotificationServiceClient(...)); + /// services.AddSnsSqsPubSubClient(); + /// services.AddMediatorDistributedNotifications(); + /// + /// + public static IServiceCollection AddSnsSqsPubSubClient( + this IServiceCollection services, + Action? configure = null) + { + var options = new SnsSqsPubSubClientOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(sp => new SnsSqsPubSubClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + options, + sp.GetRequiredService(), + sp.GetRequiredService>())); + + return services; + } +} diff --git a/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj b/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj new file mode 100644 index 00000000..a1c0f3aa --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj @@ -0,0 +1,16 @@ + + + + + net10.0 + latest + enable + Foundatio.Mediator.Distributed.Redis + + + + + + + + diff --git a/src/Foundatio.Mediator.Distributed.Redis/RedisJobStateStoreOptions.cs b/src/Foundatio.Mediator.Distributed.Redis/RedisJobStateStoreOptions.cs new file mode 100644 index 00000000..917802f8 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisJobStateStoreOptions.cs @@ -0,0 +1,18 @@ +namespace Foundatio.Mediator.Distributed.Redis; + +/// +/// Options for configuring . +/// +public class RedisJobStateStoreOptions +{ + /// + /// Key prefix for all Redis keys. Default is "fm:jobs". + /// + public string KeyPrefix { get; set; } = "fm:jobs"; + + /// + /// Default TTL for terminal job states (Completed, Failed, Cancelled). + /// Default is 24 hours. Set to null to disable auto-expiry. + /// + public TimeSpan? DefaultExpiry { get; set; } = TimeSpan.FromHours(24); +} diff --git a/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs new file mode 100644 index 00000000..84bfef8c --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using StackExchange.Redis; + +namespace Foundatio.Mediator.Distributed.Redis; + +/// +/// Redis-backed implementation of . +/// Each job is stored as a Redis Hash. Per-queue job lists are maintained as sorted sets +/// scored by creation time for efficient pagination. Cancellation uses a separate key. +/// +public sealed class RedisQueueJobStateStore : IQueueJobStateStore +{ + private readonly IConnectionMultiplexer _redis; + private readonly RedisJobStateStoreOptions _options; + + public RedisQueueJobStateStore(IConnectionMultiplexer redis, RedisJobStateStoreOptions? options = null) + { + _redis = redis; + _options = options ?? new RedisJobStateStoreOptions(); + } + + public async Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var key = JobKey(state.JobId); + + var entries = new HashEntry[] + { + new("JobId", state.JobId), + new("QueueName", state.QueueName), + new("MessageType", state.MessageType), + new("Status", ((int)state.Status).ToString(CultureInfo.InvariantCulture)), + new("Progress", state.Progress.ToString(CultureInfo.InvariantCulture)), + new("ProgressMessage", state.ProgressMessage ?? string.Empty), + new("CreatedUtc", state.CreatedUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)), + new("StartedUtc", state.StartedUtc?.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) ?? string.Empty), + new("CompletedUtc", state.CompletedUtc?.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) ?? string.Empty), + new("ErrorMessage", state.ErrorMessage ?? string.Empty), + new("LastUpdatedUtc", state.LastUpdatedUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)) + }; + + await db.HashSetAsync(key, entries).ConfigureAwait(false); + + // Add to the per-queue sorted set (scored by creation timestamp for ordering) + var queueSetKey = QueueSetKey(state.QueueName); + await db.SortedSetAddAsync(queueSetKey, state.JobId, state.CreatedUtc.ToUnixTimeMilliseconds()).ConfigureAwait(false); + + // Set TTL + var ttl = expiry ?? _options.DefaultExpiry; + if (ttl.HasValue) + { + await db.KeyExpireAsync(key, ttl.Value).ConfigureAwait(false); + await db.KeyExpireAsync(queueSetKey, ttl.Value).ConfigureAwait(false); + } + } + + public async Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var entries = await db.HashGetAllAsync(JobKey(jobId)).ConfigureAwait(false); + + if (entries.Length == 0) + return null; + + return ParseJobState(entries); + } + + public async Task> GetJobsByQueueAsync(string queueName, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var queueSetKey = QueueSetKey(queueName); + + // Get job IDs from sorted set in reverse order (newest first) + var jobIds = await db.SortedSetRangeByRankAsync(queueSetKey, skip, skip + take - 1, Order.Descending).ConfigureAwait(false); + + var results = new List(jobIds.Length); + foreach (var jobId in jobIds) + { + if (jobId.IsNullOrEmpty) + continue; + + var state = await GetJobStateAsync(jobId.ToString(), cancellationToken).ConfigureAwait(false); + if (state is not null) + results.Add(state); + } + + return results; + } + + public async Task RequestCancellationAsync(string jobId, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var key = JobKey(jobId); + + // Check if job exists and is in a non-terminal state + var statusValue = await db.HashGetAsync(key, "Status").ConfigureAwait(false); + if (statusValue.IsNullOrEmpty) + return false; + + if (int.TryParse(statusValue.ToString(), out var statusInt)) + { + var status = (QueueJobStatus)statusInt; + if (status is QueueJobStatus.Completed or QueueJobStatus.Failed or QueueJobStatus.Cancelled) + return false; + } + + // Set cancellation flag + var cancelKey = CancelKey(jobId); + await db.StringSetAsync(cancelKey, "1").ConfigureAwait(false); + + // Match the job's TTL + var jobTtl = await db.KeyTimeToLiveAsync(key).ConfigureAwait(false); + if (jobTtl.HasValue) + await db.KeyExpireAsync(cancelKey, jobTtl.Value).ConfigureAwait(false); + + return true; + } + + public Task IsCancellationRequestedAsync(string jobId, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + return db.KeyExistsAsync(CancelKey(jobId)); + } + + public async Task RemoveJobStateAsync(string jobId, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var key = JobKey(jobId); + + // Get queue name before deleting so we can clean up the sorted set + var queueName = await db.HashGetAsync(key, "QueueName").ConfigureAwait(false); + + await db.KeyDeleteAsync(key).ConfigureAwait(false); + await db.KeyDeleteAsync(CancelKey(jobId)).ConfigureAwait(false); + + if (!queueName.IsNullOrEmpty) + await db.SortedSetRemoveAsync(QueueSetKey(queueName.ToString()), jobId).ConfigureAwait(false); + } + + public Task IncrementCounterAsync(string queueName, string counterName, long value = 1, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var key = CountersKey(queueName); + return db.HashIncrementAsync(key, counterName, value); + } + + public async Task> GetCountersAsync(string queueName, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var key = CountersKey(queueName); + var entries = await db.HashGetAllAsync(key).ConfigureAwait(false); + + var result = new Dictionary(entries.Length); + foreach (var entry in entries) + { + if (entry.Value.TryParse(out long val)) + result[entry.Name.ToString()] = val; + } + + return result; + } + + private string JobKey(string jobId) => $"{_options.KeyPrefix}:{jobId}"; + private string CancelKey(string jobId) => $"{_options.KeyPrefix}:{jobId}:cancel"; + private string QueueSetKey(string queueName) => $"{_options.KeyPrefix}:queues:{queueName}"; + private string CountersKey(string queueName) => $"{_options.KeyPrefix}:counters:{queueName}"; + + public async Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var queueSetKey = QueueSetKey(queueName); + var prefix = _options.KeyPrefix; + + // Build status filter string for Lua (e.g., ",0,1,") + var statusFilter = "," + string.Join(",", statuses.Select(s => ((int)s).ToString(CultureInfo.InvariantCulture))) + ","; + + // Lua script: iterate sorted set in descending order, check Status hash field, collect matching job IDs + const string lua = """ + local queueKey = KEYS[1] + local prefix = ARGV[1] + local statusFilter = ARGV[2] + local skip = tonumber(ARGV[3]) + local take = tonumber(ARGV[4]) + + local all = redis.call('ZREVRANGE', queueKey, 0, -1) + local matched = 0 + local collected = 0 + local results = {} + + for _, jobId in ipairs(all) do + local status = redis.call('HGET', prefix .. ':' .. jobId, 'Status') + if status and string.find(statusFilter, ',' .. status .. ',', 1, true) then + matched = matched + 1 + if matched > skip then + table.insert(results, jobId) + collected = collected + 1 + if collected >= take then break end + end + end + end + + return results + """; + + var scriptResult = await db.ScriptEvaluateAsync(lua, + [queueSetKey], + [prefix, statusFilter, skip, take]).ConfigureAwait(false); + + var jobIds = (RedisResult[]?)scriptResult; + if (jobIds is null || jobIds.Length == 0) + return []; + + var results = new List(jobIds.Length); + foreach (var jobId in jobIds) + { + var state = await GetJobStateAsync(jobId.ToString()!, cancellationToken).ConfigureAwait(false); + if (state is not null) + results.Add(state); + } + + return results; + } + + public async Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var queueSetKey = QueueSetKey(queueName); + var prefix = _options.KeyPrefix; + var statusStr = ((int)status).ToString(CultureInfo.InvariantCulture); + + const string lua = """ + local queueKey = KEYS[1] + local prefix = ARGV[1] + local targetStatus = ARGV[2] + + local all = redis.call('ZRANGE', queueKey, 0, -1) + local count = 0 + + for _, jobId in ipairs(all) do + local status = redis.call('HGET', prefix .. ':' .. jobId, 'Status') + if status == targetStatus then + count = count + 1 + end + end + + return count + """; + + var result = await db.ScriptEvaluateAsync(lua, + [queueSetKey], + [prefix, statusStr]).ConfigureAwait(false); + + return (long)result; + } + + private static QueueJobState ParseJobState(HashEntry[] entries) + { + var dict = entries.ToDictionary(e => e.Name.ToString(), e => e.Value.ToString()); + + return new QueueJobState + { + JobId = dict.GetValueOrDefault("JobId") ?? string.Empty, + QueueName = dict.GetValueOrDefault("QueueName") ?? string.Empty, + MessageType = dict.GetValueOrDefault("MessageType") ?? string.Empty, + Status = int.TryParse(dict.GetValueOrDefault("Status"), out var s) ? (QueueJobStatus)s : QueueJobStatus.Queued, + Progress = int.TryParse(dict.GetValueOrDefault("Progress"), out var p) ? p : 0, + ProgressMessage = NullIfEmpty(dict.GetValueOrDefault("ProgressMessage")), + CreatedUtc = ParseDateTimeOffset(dict.GetValueOrDefault("CreatedUtc")), + StartedUtc = ParseNullableDateTimeOffset(dict.GetValueOrDefault("StartedUtc")), + CompletedUtc = ParseNullableDateTimeOffset(dict.GetValueOrDefault("CompletedUtc")), + ErrorMessage = NullIfEmpty(dict.GetValueOrDefault("ErrorMessage")), + LastUpdatedUtc = ParseDateTimeOffset(dict.GetValueOrDefault("LastUpdatedUtc")) + }; + } + + private static DateTimeOffset ParseDateTimeOffset(string? value) + => long.TryParse(value, out var ms) ? DateTimeOffset.FromUnixTimeMilliseconds(ms) : DateTimeOffset.MinValue; + + private static DateTimeOffset? ParseNullableDateTimeOffset(string? value) + => string.IsNullOrEmpty(value) ? null : long.TryParse(value, out var ms) ? DateTimeOffset.FromUnixTimeMilliseconds(ms) : null; + + private static string? NullIfEmpty(string? value) + => string.IsNullOrEmpty(value) ? null : value; +} diff --git a/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs b/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs new file mode 100644 index 00000000..84270aef --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace Foundatio.Mediator.Distributed.Redis; + +/// +/// Extension methods for registering the Redis-backed queue job state store. +/// +public static class RedisServiceExtensions +{ + /// + /// Registers a Redis-backed for tracking queue job state. + /// Requires an to be registered in DI. + /// + /// The service collection. + /// Optional configuration callback. + /// The service collection for chaining. + /// + /// + /// services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost")); + /// services.AddMediatorRedisJobStateStore(); + /// + /// + public static IServiceCollection AddMediatorRedisJobStateStore( + this IServiceCollection services, + Action? configure = null) + { + var options = new RedisJobStateStoreOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(sp => + new RedisQueueJobStateStore( + sp.GetRequiredService(), + sp.GetService())); + + return services; + } +} diff --git a/src/Foundatio.Mediator.Queues/AssemblyInfo.cs b/src/Foundatio.Mediator.Distributed/AssemblyInfo.cs similarity index 100% rename from src/Foundatio.Mediator.Queues/AssemblyInfo.cs rename to src/Foundatio.Mediator.Distributed/AssemblyInfo.cs diff --git a/src/Foundatio.Mediator.Distributed/DistributedContext.cs b/src/Foundatio.Mediator.Distributed/DistributedContext.cs new file mode 100644 index 00000000..8cb56354 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedContext.cs @@ -0,0 +1,34 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Ambient context that indicates the current execution scope originated from +/// distributed infrastructure (e.g., a pub/sub bus or remote queue). +/// Middleware such as checks this to avoid +/// re-enqueueing messages that have already been dispatched through shared infrastructure. +/// +public static class DistributedContext +{ + private static readonly AsyncLocal _isNotification = new(); + + /// + /// Gets whether the current execution scope is processing a notification + /// received from the distributed bus. + /// + public static bool IsNotification => _isNotification.Value; + + /// + /// Enters a notification scope. The returned + /// restores the previous value when disposed. + /// + public static IDisposable BeginNotificationScope() + { + var previous = _isNotification.Value; + _isNotification.Value = true; + return new NotificationScope(previous); + } + + private sealed class NotificationScope(bool previous) : IDisposable + { + public void Dispose() => _isNotification.Value = previous; + } +} diff --git a/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs b/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs new file mode 100644 index 00000000..b396ce0c --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Hosted service that pre-creates all queues and topics under a single trace span +/// so startup infrastructure calls are grouped rather than appearing as individual root traces. +/// Registered before queue/notification workers so resources exist before polling begins. +/// +internal sealed class DistributedInfrastructureInitializer( + IQueueClient? queueClient, + IPubSubClient? pubSubClient, + DistributedInfrastructureOptions options, + ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + if (options.QueueNames.Count == 0 && options.TopicNames.Count == 0) + return; + + using var activity = MediatorActivitySource.Instance.StartActivity("Mediator Infrastructure Setup"); + + if (options.QueueNames.Count > 0 && queueClient is not null) + { + logger.LogInformation("Ensuring {Count} queue(s) exist: {Queues}", options.QueueNames.Count, options.QueueNames); + await queueClient.EnsureQueuesAsync(options.QueueNames, cancellationToken).ConfigureAwait(false); + } + + if (options.TopicNames.Count > 0 && pubSubClient is not null) + { + logger.LogInformation("Ensuring {Count} topic(s) exist: {Topics}", options.TopicNames.Count, options.TopicNames); + await pubSubClient.EnsureTopicsAsync(options.TopicNames, cancellationToken).ConfigureAwait(false); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +/// +/// Collects queue and topic names during service registration for use by +/// at startup. +/// +internal sealed class DistributedInfrastructureOptions +{ + public List QueueNames { get; } = []; + public List TopicNames { get; } = []; +} diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs new file mode 100644 index 00000000..e0c8e414 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Threading.Channels; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Options for configuring distributed notification fan-out. +/// +public class DistributedNotificationOptions +{ + /// + /// Unique identifier for this host instance. Messages received from the bus + /// with a matching host ID are skipped to prevent double-processing. + /// Defaults to a new GUID if not set. + /// + public string HostId { get; set; } = Guid.NewGuid().ToString("N"); + + /// + /// The topic name used for publishing and subscribing to distributed notifications. + /// Defaults to "distributed-notifications". + /// + public string Topic { get; set; } = "distributed-notifications"; + + /// + /// Custom JSON serializer options for notification serialization/deserialization. + /// When null, is used. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Maximum capacity of the outbound subscription buffer. + /// When full, the behavior is controlled by . + /// Default is 1000. + /// + public int MaxCapacity { get; set; } = 1000; + + /// + /// Behavior when the outbound subscription buffer is full. + /// Default is to provide backpressure + /// and avoid dropping notifications. + /// + public BoundedChannelFullMode FullMode { get; set; } = BoundedChannelFullMode.Wait; +} diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs new file mode 100644 index 00000000..20e5b8f9 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs @@ -0,0 +1,254 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Background service that bridges local events +/// to a remote (outbound) and re-publishes inbound bus messages +/// to the local mediator. +/// +/// +/// Outbound loop: uses mediator.SubscribeAsync<IDistributedNotification>() +/// to tap into all locally published distributed notifications, then serializes and publishes +/// them to the pub/sub client. Messages that arrived from the bus (tracked by reference identity +/// in ) are skipped to prevent re-broadcast loops. +/// +/// Inbound loop: subscribes to the bus topic and, for each received message, +/// checks the header. If it matches this host's ID the +/// message is skipped (self-delivery). Otherwise the message is deserialized, added to the +/// set, and published locally via mediator.PublishAsync(). +/// The reference set entry is removed in a finally block. +/// +public sealed class DistributedNotificationWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IPubSubClient _bus; + private readonly DistributedNotificationOptions _options; + private readonly JsonSerializerOptions _jsonOptions; + private readonly ILogger _logger; + + /// + /// Tracks notification objects that arrived from the bus and are currently being + /// re-published locally. The outbound loop checks this set by reference identity + /// and skips any match, preventing infinite re-broadcast. + /// + private readonly ConcurrentDictionary _inboundMessages = new(ReferenceEqualityComparer.Instance); + + private readonly TimeProvider _timeProvider; + + public DistributedNotificationWorker( + IServiceScopeFactory scopeFactory, + IPubSubClient bus, + DistributedNotificationOptions options, + ILogger logger, + TimeProvider? timeProvider = null) + { + _scopeFactory = scopeFactory; + _bus = bus; + _options = options; + _jsonOptions = options.JsonSerializerOptions ?? JsonSerializerOptions.Default; + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "Distributed notification worker starting (HostId={HostId}, Topic={Topic})", + _options.HostId, _options.Topic); + + var outboundTask = RunOutboundLoopAsync(stoppingToken); + var inboundTask = RunInboundLoopAsync(stoppingToken); + + await Task.WhenAll(outboundTask, inboundTask).ConfigureAwait(false); + + _logger.LogInformation("Distributed notification worker stopped"); + } + + /// + /// Reads from the local mediator subscription stream and publishes to the bus. + /// + private async Task RunOutboundLoopAsync(CancellationToken stoppingToken) + { + try + { + // Create a long-lived scope for the outbound subscription stream + await using var scope = _scopeFactory.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + + var subscriberOptions = new SubscriberOptions + { + MaxCapacity = _options.MaxCapacity, + FullMode = _options.FullMode + }; + + await foreach (var envelope in mediator.SubscribeAsync>(stoppingToken, subscriberOptions).ConfigureAwait(false)) + { + var notification = envelope.Message; + + // Skip messages that arrived from the bus. TryRemove atomically checks and cleans + // up the tracking entry, avoiding the race where a finally block removed the entry + // before this loop had a chance to read from the channel. + if (_inboundMessages.TryRemove(notification, out _)) + continue; + + try + { + var messageType = notification.GetType(); + var body = JsonSerializer.SerializeToUtf8Bytes(notification, messageType, _jsonOptions); + + var headers = new Dictionary + { + [MessageHeaders.MessageType] = messageType.AssemblyQualifiedName!, + [MessageHeaders.OriginHostId] = _options.HostId, + [MessageHeaders.PublishedAt] = _timeProvider.GetUtcNow().ToString("O") + }; + + // Start a producer activity parented to the original publisher's trace + // (e.g. the HTTP request handler) so the SNS.Publish span is in the same trace. + using var activity = MediatorActivitySource.Instance.StartActivity( + $"Publish {messageType.Name}", + ActivityKind.Producer, + envelope.ActivityContext); + + // Propagate W3C trace context so downstream consumers appear in the same trace + var activeActivity = Activity.Current; + if (activeActivity is not null) + { + headers[MessageHeaders.TraceParent] = activeActivity.Id!; + if (activeActivity.TraceStateString is { Length: > 0 } traceState) + headers[MessageHeaders.TraceState] = traceState; + } + + await _bus.PublishAsync(_options.Topic, body, headers, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish distributed notification {MessageType} to bus", + notification.GetType().Name); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown + } + } + + /// + /// Subscribes to the bus topic and re-publishes received messages locally. + /// + private async Task RunInboundLoopAsync(CancellationToken stoppingToken) + { + IAsyncDisposable? subscription = null; + try + { + subscription = await _bus.SubscribeAsync(_options.Topic, async (message, ct) => + { + await ProcessInboundMessageAsync(message, ct).ConfigureAwait(false); + }, stoppingToken).ConfigureAwait(false); + + // Keep alive until cancellation + await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown + } + finally + { + if (subscription is not null) + await subscription.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task ProcessInboundMessageAsync(PubSubMessage message, CancellationToken cancellationToken) + { + // Skip messages from this host (self-delivery prevention) + if (message.Headers.TryGetValue(MessageHeaders.OriginHostId, out var originHostId) + && string.Equals(originHostId, _options.HostId, StringComparison.Ordinal)) + { + return; + } + + if (!message.Headers.TryGetValue(MessageHeaders.MessageType, out var typeName) || string.IsNullOrEmpty(typeName)) + { + _logger.LogWarning("Received bus message without {Header} header, skipping", MessageHeaders.MessageType); + return; + } + + var messageType = Type.GetType(typeName); + if (messageType is null) + { + _logger.LogWarning("Cannot resolve type '{TypeName}' from bus message, skipping", typeName); + return; + } + + object? notification; + try + { + notification = JsonSerializer.Deserialize(message.Body.Span, messageType, _jsonOptions); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize bus message as {TypeName}", typeName); + return; + } + + if (notification is null) + { + _logger.LogWarning("Deserialized bus message as {TypeName} was null, skipping", typeName); + return; + } + + // Mark by reference so the outbound loop skips this message. + // Removal happens in the outbound loop (TryRemove) to avoid a race where this + // finally block runs before the outbound loop reads from the channel. + _inboundMessages.TryAdd(notification, 0); + bool published = false; + try + { + // Restore trace context from the publishing node so this processing + // appears as a child span of the original operation + ActivityContext parentContext = default; + if (message.Headers.TryGetValue(MessageHeaders.TraceParent, out var traceParent) + && ActivityContext.TryParse(traceParent, message.Headers.GetValueOrDefault(MessageHeaders.TraceState), out var parsed)) + { + parentContext = parsed; + } + + using var activity = MediatorActivitySource.Instance.StartActivity( + $"Process {messageType.Name}", + ActivityKind.Consumer, + parentContext); + + // Mark the scope as an inbound notification so middleware (e.g., QueueMiddleware) + // skips re-enqueueing β€” the originating node already enqueued to shared infra. + using var distributedScope = DistributedContext.BeginNotificationScope(); + + // Create a scope per inbound message for proper scoped service lifetime + await using var scope = _scopeFactory.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + + // Publish skips auth automatically via the publish delegate path + await mediator.PublishAsync(notification, cancellationToken).ConfigureAwait(false); + published = true; + } + finally + { + // Only clean up here if publish failed β€” the outbound loop will never see the + // message, so we must remove the tracking entry ourselves. + if (!published) + _inboundMessages.TryRemove(notification, out _); + } + } +} diff --git a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs new file mode 100644 index 00000000..47b3190b --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs @@ -0,0 +1,241 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Options for configuring distributed queue support. +/// +public class DistributedOptions +{ + /// + /// Custom JSON serializer options for message serialization/deserialization. + /// When null, is used. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// When set, only workers for queues in the matching group will be started. + /// When null (default), all queue workers are started. + /// + public string? Group { get; set; } +} + +/// +/// Extension methods for registering distributed queue support for Foundatio.Mediator. +/// +public static class DistributedServiceExtensions +{ + /// + /// Adds distributed queue processing support to Foundatio.Mediator. + /// Handlers decorated with will have their messages + /// serialized and sent to a queue for asynchronous processing. + /// + /// The service collection. + /// Optional configuration callback for . + /// The service collection for chaining. + /// + /// + /// services.AddMediator(); + /// services.AddMediatorDistributed(); + /// + /// // Or with a custom transport: + /// services.AddSingleton<IQueueClient, SqsQueueClient>(); + /// services.AddMediatorDistributed(opts => opts.Group = "order-processing"); + /// + /// + public static IServiceCollection AddMediatorDistributed( + this IServiceCollection services, + Action? configure = null) + { + // Prevent double registration + if (services.Any(sd => sd.ServiceType == typeof(QueueMiddleware))) + return services; + + var registry = services.GetHandlerRegistry() + ?? throw new InvalidOperationException( + "AddMediatorDistributed requires AddMediator to be called first."); + + var options = new DistributedOptions(); + configure?.Invoke(options); + + // Register options as singleton for QueueMiddleware and QueueWorker to consume + services.AddSingleton(options); + + var queueHandlers = registry.GetHandlersWithAttribute(); + if (queueHandlers.Count == 0) + return services; + + // Register IQueueClient if not already registered (default: in-memory) + if (!services.Any(sd => sd.ServiceType == typeof(IQueueClient))) + services.AddSingleton(); + + // Register the middleware + services.AddTransient(); + + // Register the worker registry + var workerRegistry = new QueueWorkerRegistry(); + services.AddSingleton(workerRegistry); + + // Track whether any handler uses progress tracking + bool anyTrackProgress = false; + + // Collect queue names for startup initialization + var infraOptions = GetOrAddInfrastructureOptions(services); + + // Register a QueueWorker for each [Queue]-decorated handler + foreach (var handler in queueHandlers) + { + var messageType = handler.MessageType; + if (messageType is null) + continue; + + var queueAttr = handler.GetPreferredAttribute()?.Attribute as QueueAttribute; + var queueName = !string.IsNullOrWhiteSpace(queueAttr?.QueueName) + ? queueAttr!.QueueName! + : messageType.Name; + + // Apply group filtering + var group = queueAttr?.Group; + if (options.Group is not null && !string.Equals(options.Group, group, StringComparison.OrdinalIgnoreCase)) + continue; + + infraOptions.QueueNames.Add(queueName); + infraOptions.QueueNames.Add($"{queueName}-dead-letter"); + + var visibilityTimeout = TimeSpan.FromMinutes(5); + if (!string.IsNullOrWhiteSpace(queueAttr?.Timeout) && TimeSpan.TryParse(queueAttr!.Timeout, out var parsed)) + visibilityTimeout = parsed; + + var retryDelay = TimeSpan.FromSeconds(5); + if (!string.IsNullOrWhiteSpace(queueAttr?.RetryDelay) && TimeSpan.TryParse(queueAttr!.RetryDelay, out var parsedDelay)) + retryDelay = parsedDelay; + + var trackProgress = queueAttr?.TrackProgress ?? false; + if (trackProgress) + anyTrackProgress = true; + + var workerOptions = new QueueWorkerOptions + { + QueueName = queueName, + MessageType = messageType, + Registration = handler, + Concurrency = queueAttr?.Concurrency ?? 1, + PrefetchCount = queueAttr?.PrefetchCount ?? 1, + VisibilityTimeout = visibilityTimeout, + MaxRetries = queueAttr?.MaxRetries ?? 2, + RetryPolicy = queueAttr?.RetryPolicy ?? QueueRetryPolicy.Exponential, + RetryDelay = retryDelay, + Group = group, + AutoComplete = queueAttr?.AutoComplete ?? true, + TrackProgress = trackProgress + }; + + // Create and register worker info for the dashboard + var workerInfo = new QueueWorkerInfo + { + QueueName = queueName, + MessageTypeName = messageType.FullName ?? messageType.Name, + Concurrency = workerOptions.Concurrency, + PrefetchCount = workerOptions.PrefetchCount, + MaxRetries = workerOptions.MaxRetries, + VisibilityTimeout = workerOptions.VisibilityTimeout, + Group = workerOptions.Group, + RetryPolicy = workerOptions.RetryPolicy, + TrackProgress = workerOptions.TrackProgress + }; + workerRegistry.Register(workerInfo); + + // Register as a hosted service using a factory so each worker gets its own options + services.AddSingleton(sp => new QueueWorker( + sp.GetRequiredService(), + sp.GetRequiredService(), + workerOptions, + sp.GetService(), + sp.GetRequiredService>(), + workerInfo, + sp.GetService(), + sp.GetService())); + } + + // Register default in-memory state store if any handler uses progress tracking and no store is registered + if (anyTrackProgress && !services.Any(sd => sd.ServiceType == typeof(IQueueJobStateStore))) + services.AddSingleton(); + + return services; + } + + /// + /// Adds distributed notification fan-out support to Foundatio.Mediator. + /// Notifications implementing will be + /// automatically published to a pub/sub client and re-published on all other nodes + /// in the cluster. + /// + /// The service collection. + /// Optional configuration callback for . + /// The service collection for chaining. + /// + /// + /// services.AddMediator(); + /// services.AddMediatorDistributedNotifications(); + /// + /// // Or with a custom transport: + /// services.AddSingleton<IPubSubClient, SnsSqsPubSubClient>(); + /// services.AddMediatorDistributedNotifications(opts => + /// { + /// opts.Topic = "my-app-notifications"; + /// }); + /// + /// + public static IServiceCollection AddMediatorDistributedNotifications( + this IServiceCollection services, + Action? configure = null) + { + // Prevent double registration + if (services.Any(sd => sd.ServiceType == typeof(DistributedNotificationOptions))) + return services; + + var options = new DistributedNotificationOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + + // Register IPubSubClient if not already registered (default: in-memory) + if (!services.Any(sd => sd.ServiceType == typeof(IPubSubClient))) + services.AddSingleton(); + + // Collect topic name for startup initialization + var infraOptions = GetOrAddInfrastructureOptions(services); + infraOptions.TopicNames.Add(options.Topic); + + // Register the background worker + services.AddSingleton(sp => new DistributedNotificationWorker( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + return services; + } + + private static DistributedInfrastructureOptions GetOrAddInfrastructureOptions(IServiceCollection services) + { + var descriptor = services.FirstOrDefault(sd => sd.ServiceType == typeof(DistributedInfrastructureOptions)); + if (descriptor?.ImplementationInstance is DistributedInfrastructureOptions existing) + return existing; + + var infraOptions = new DistributedInfrastructureOptions(); + services.AddSingleton(infraOptions); + + // Register the initializer β€” runs before workers because it's registered first + services.AddSingleton(sp => new DistributedInfrastructureInitializer( + sp.GetService(), + sp.GetService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + return infraOptions; + } +} diff --git a/src/Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj similarity index 58% rename from src/Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj rename to src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj index 41fe3929..a2b1cc5f 100644 --- a/src/Foundatio.Mediator.Queues/Foundatio.Mediator.Queues.csproj +++ b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj @@ -5,14 +5,12 @@ net10.0 latest enable - Foundatio.Mediator.Queues + Foundatio.Mediator.Distributed - - - + diff --git a/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs b/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs new file mode 100644 index 00000000..78578ccb --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs @@ -0,0 +1,154 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Handles queue dashboard queries: listing workers, viewing stats, and managing tracked jobs. +/// Endpoints are auto-generated under /api/queues by the source generator. +/// +[HandlerEndpointGroup("Queues")] +[HandlerAllowAnonymous] +public class QueueDashboardHandler +{ + private readonly IQueueWorkerRegistry _registry; + private readonly IQueueClient _queueClient; + private readonly IQueueJobStateStore? _stateStore; + + public QueueDashboardHandler(IQueueWorkerRegistry registry, IQueueClient queueClient, IQueueJobStateStore? stateStore = null) + { + _registry = registry; + _queueClient = queueClient; + _stateStore = stateStore; + } + + /// + /// Gets all registered queue workers with their configuration and runtime stats. + /// + public async Task>> HandleAsync(GetQueueWorkers query, CancellationToken ct) + { + var workers = _registry.GetWorkers(); + var results = new List(workers.Count); + + foreach (var worker in workers) + { + QueueStats? stats = null; + try + { + stats = await _queueClient.GetQueueStatsAsync(worker.QueueName, ct).ConfigureAwait(false); + } + catch + { + // Stats may not be available for all transports + } + + results.Add(ToSummary(worker, stats)); + } + + return results; + } + + /// + /// Gets a specific queue worker by queue name, including queue stats. + /// + public async Task> HandleAsync(GetQueueWorker query, CancellationToken ct) + { + var worker = _registry.GetWorker(query.QueueName); + if (worker is null) + return Result.NotFound($"Queue worker '{query.QueueName}' not found"); + + QueueStats? stats = null; + try + { + stats = await _queueClient.GetQueueStatsAsync(query.QueueName, ct).ConfigureAwait(false); + } + catch + { + // Stats may not be available for all transports + } + + return ToSummary(worker, stats); + } + + /// + /// Gets tracked jobs for a specific queue, ordered by creation time descending. + /// + public async Task>> HandleAsync(GetQueueJobs query, CancellationToken ct) + { + if (_stateStore is null) + return Result.Error("Job state tracking is not configured."); + + var jobs = await _stateStore.GetJobsByQueueAsync(query.QueueName, query.Skip, query.Take, ct).ConfigureAwait(false); + return Result>.Ok(jobs); + } + + /// + /// Gets a dashboard view: queued count, active (processing) jobs, and recent terminal jobs. + /// + public async Task> HandleAsync(GetQueueJobDashboard query, CancellationToken ct) + { + if (_stateStore is null) + return Result.Error("Job state tracking is not configured."); + + var queuedCount = await _stateStore.GetJobCountByStatusAsync(query.QueueName, QueueJobStatus.Queued, ct).ConfigureAwait(false); + + var activeJobs = await _stateStore.GetJobsByStatusAsync( + query.QueueName, [QueueJobStatus.Processing], 0, 200, ct).ConfigureAwait(false); + + var recentJobs = await _stateStore.GetJobsByStatusAsync( + query.QueueName, [QueueJobStatus.Completed, QueueJobStatus.Failed, QueueJobStatus.Cancelled], + 0, query.RecentTerminalCount ?? 20, ct).ConfigureAwait(false); + + return new QueueJobDashboardView + { + QueuedCount = queuedCount, + ActiveJobs = activeJobs, + RecentJobs = recentJobs + }; + } + + /// + /// Gets the state of a specific tracked job. + /// + public async Task> HandleAsync(GetQueueJob query, CancellationToken ct) + { + if (_stateStore is null) + return Result.Error("Job state tracking is not configured."); + + var state = await _stateStore.GetJobStateAsync(query.JobId, ct).ConfigureAwait(false); + if (state is null) + return Result.NotFound($"Job '{query.JobId}' not found"); + + return state; + } + + /// + /// Requests cancellation of a tracked job. + /// + public async Task> HandleAsync(CancelQueueJob command, CancellationToken ct) + { + if (_stateStore is null) + return Result.Error("Job state tracking is not configured."); + + var requested = await _stateStore.RequestCancellationAsync(command.JobId, ct).ConfigureAwait(false); + if (!requested) + return Result.NotFound($"Job '{command.JobId}' not found or already in a terminal state"); + + return new QueueJobCancellationResult(command.JobId, true); + } + + private static QueueWorkerSummary ToSummary(QueueWorkerInfo worker, QueueStats? stats) => new() + { + QueueName = worker.QueueName, + MessageTypeName = worker.MessageTypeName, + Concurrency = worker.Concurrency, + PrefetchCount = worker.PrefetchCount, + MaxRetries = worker.MaxRetries, + VisibilityTimeout = worker.VisibilityTimeout.ToString(), + Group = worker.Group, + RetryPolicy = worker.RetryPolicy.ToString(), + TrackProgress = worker.TrackProgress, + IsRunning = worker.IsRunning, + MessagesProcessed = worker.MessagesProcessed, + MessagesFailed = worker.MessagesFailed, + MessagesDeadLettered = worker.MessagesDeadLettered, + Stats = stats + }; +} diff --git a/src/Foundatio.Mediator.Distributed/IDistributedNotification.cs b/src/Foundatio.Mediator.Distributed/IDistributedNotification.cs new file mode 100644 index 00000000..3b5878e7 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IDistributedNotification.cs @@ -0,0 +1,14 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Marker interface for notifications that should be distributed across all nodes +/// in a scale-out cluster. Extends so that +/// mediator.PublishAsync() dispatches to local handlers as usual, +/// while the distributed infrastructure fans the message out to remote nodes. +/// +/// +/// +/// public record OrderCreated(Guid OrderId, Guid CustomerId) : IDistributedNotification; +/// +/// +public interface IDistributedNotification : INotification { } diff --git a/src/Foundatio.Mediator.Distributed/IPubSubClient.cs b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs new file mode 100644 index 00000000..95e66e86 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs @@ -0,0 +1,33 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Transport-agnostic pub/sub abstraction used by the distributed notification system. +/// Implementations fan messages out to all subscribers (topic-based publish/subscribe). +/// +public interface IPubSubClient +{ + /// + /// Publishes a message to all subscribers of the specified topic. + /// + /// The topic to publish to. + /// The serialized message body. + /// Optional transport headers. + /// A cancellation token. + Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default); + + /// + /// Subscribes to a topic. The returned unsubscribes when disposed. + /// + /// The topic to subscribe to. + /// Callback invoked for each received message. + /// A cancellation token. + /// A handle that unsubscribes when disposed. + Task SubscribeAsync(string topic, Func handler, CancellationToken cancellationToken = default); + + /// + /// Ensures the specified topics and per-node subscription infrastructure exist. + /// Implementations create topics, per-node queues, and subscriptions so that + /// can skip to polling without additional API calls. + /// + Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) => Task.CompletedTask; +} diff --git a/src/Foundatio.Mediator.Distributed/IQueueClient.cs b/src/Foundatio.Mediator.Distributed/IQueueClient.cs new file mode 100644 index 00000000..e6a64eb4 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IQueueClient.cs @@ -0,0 +1,67 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Transport-agnostic contract for sending and receiving queue messages. +/// Implementations map to specific transports (in-memory, SQS, RabbitMQ, etc.). +/// +public interface IQueueClient +{ + /// + /// Sends a single message to the specified queue. + /// + Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default); + + /// + /// Sends a batch of messages to the specified queue. + /// + Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default); + + /// + /// Receives up to messages from the specified queue. + /// Returns an empty list when no messages are available after a transport-specific wait + /// (e.g., SQS long-poll, RabbitMQ prefetch, in-memory channel wait). + /// + Task> ReceiveAsync(string queueName, int maxCount, CancellationToken cancellationToken = default); + + /// + /// Marks a message as successfully processed and removes it from the queue. + /// + Task CompleteAsync(QueueMessage message, CancellationToken cancellationToken = default); + + /// + /// Returns a message to the queue for reprocessing. + /// When is zero (default) the message becomes visible immediately; + /// otherwise it remains invisible until the delay elapses. Used for retry backoff strategies. + /// + Task AbandonAsync(QueueMessage message, TimeSpan delay = default, CancellationToken cancellationToken = default); + + /// + /// Extends the visibility timeout of a message so it remains invisible to other consumers + /// for an additional duration. Used by long-running handlers + /// to prevent the message from being redelivered while still processing. + /// + Task RenewTimeoutAsync(QueueMessage message, TimeSpan extension, CancellationToken cancellationToken = default); + + /// + /// Moves a message to the dead-letter queue for the specified queue. + /// The message body and headers are preserved, with additional dead-letter metadata added. + /// + /// The message to dead-letter. + /// A human-readable reason for dead-lettering. + /// A cancellation token. + Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default) + => CompleteAsync(message, cancellationToken); + + /// + /// Ensures the specified queues exist, creating them if necessary. + /// Implementations may batch the operations for efficiency. + /// + Task EnsureQueuesAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + /// Gets transport-level statistics for the specified queue. + /// Not all transports support all metrics; unsupported values will be zero. + /// + Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) + => Task.FromResult(new QueueStats { QueueName = queueName }); +} diff --git a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs new file mode 100644 index 00000000..69fefdb2 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs @@ -0,0 +1,91 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Pluggable store for tracking queue job state, progress, and cancellation. +/// Implementations must be thread-safe. +/// +public interface IQueueJobStateStore +{ + /// + /// Persists or updates a job state entry. When is provided, + /// the entry should be automatically removed after the specified duration. + /// + Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves the state for a specific job. + /// + /// The job state, or null if the job ID is not found or has expired. + Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default); + + /// + /// Retrieves tracked jobs for a specific queue, ordered by creation time descending. + /// + Task> GetJobsByQueueAsync(string queueName, int skip = 0, int take = 50, CancellationToken cancellationToken = default); + + /// + /// Requests cancellation of a job. The worker will observe this on the next + /// cancellation poll or progress report and cancel the handler's . + /// + /// true if the job was found and cancellation was requested; false otherwise. + Task RequestCancellationAsync(string jobId, CancellationToken cancellationToken = default); + + /// + /// Checks whether cancellation has been requested for a job. + /// Called by the worker's cancellation polling loop and on progress reports. + /// + Task IsCancellationRequestedAsync(string jobId, CancellationToken cancellationToken = default); + + /// + /// Removes a job state entry. + /// + Task RemoveJobStateAsync(string jobId, CancellationToken cancellationToken = default); + + /// + /// Atomically increments a named counter for a queue. + /// Used to track messages processed, failed, and dead-lettered across all nodes. + /// + /// The queue name. + /// Counter name (e.g., "processed", "failed", "dead_lettered"). + /// The amount to increment by. Default is 1. + /// Cancellation token. + Task IncrementCounterAsync(string queueName, string counterName, long value = 1, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + /// Retrieves all counters for a queue (e.g., processed, failed, dead_lettered). + /// + /// The queue name. + /// Cancellation token. + /// A dictionary of counter name to value. Empty if no counters exist. + Task> GetCountersAsync(string queueName, CancellationToken cancellationToken = default) + => Task.FromResult>(new Dictionary()); + + /// + /// Retrieves tracked jobs filtered by one or more statuses, ordered by creation time descending. + /// + Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + { + // Default: fall back to GetJobsByQueueAsync and filter in memory + return GetJobsByQueueAsync(queueName, 0, skip + take + 500, cancellationToken) + .ContinueWith(t => + { + var statusSet = new HashSet(statuses); + IReadOnlyList result = t.Result + .Where(j => statusSet.Contains(j.Status)) + .Skip(skip) + .Take(take) + .ToList(); + return result; + }, TaskContinuationOptions.OnlyOnRanToCompletion); + } + + /// + /// Counts jobs in a specific status for a queue. + /// + Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + { + return GetJobsByQueueAsync(queueName, 0, int.MaxValue, cancellationToken) + .ContinueWith(t => (long)t.Result.Count(j => j.Status == status), TaskContinuationOptions.OnlyOnRanToCompletion); + } +} diff --git a/src/Foundatio.Mediator.Distributed/IQueueWorkerRegistry.cs b/src/Foundatio.Mediator.Distributed/IQueueWorkerRegistry.cs new file mode 100644 index 00000000..de2a63f6 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IQueueWorkerRegistry.cs @@ -0,0 +1,17 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Provides read-only access to registered queue workers and their runtime statistics. +/// +public interface IQueueWorkerRegistry +{ + /// + /// Gets all registered queue workers. + /// + IReadOnlyList GetWorkers(); + + /// + /// Gets the worker info for a specific queue, or null if not found. + /// + QueueWorkerInfo? GetWorker(string queueName); +} diff --git a/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs new file mode 100644 index 00000000..fcd9a985 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs @@ -0,0 +1,96 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace Foundatio.Mediator.Distributed; + +/// +/// In-process pub/sub client backed by . +/// Useful for testing and single-process scenarios where distributed fan-out +/// collapses to local delivery. +/// +public sealed class InMemoryPubSubClient : IPubSubClient, IDisposable +{ + private readonly ConcurrentDictionary> _subscriptions = new(); + + /// + public Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default) + { + if (!_subscriptions.TryGetValue(topic, out var entries)) + return Task.CompletedTask; + + var message = new PubSubMessage + { + Body = body, + Headers = headers is IReadOnlyDictionary ro + ? ro + : new Dictionary(headers ?? new Dictionary()) + }; + + foreach (var entry in entries.Values) + entry.Writer.TryWrite(message); + + return Task.CompletedTask; + } + + /// + public Task SubscribeAsync(string topic, Func handler, CancellationToken cancellationToken = default) + { + var entries = _subscriptions.GetOrAdd(topic, _ => new ConcurrentDictionary()); + + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleWriter = false, + SingleReader = true + }); + + var id = Guid.NewGuid(); + var entry = new SubscriptionEntry(channel.Writer); + entries.TryAdd(id, entry); + + // Start consumer task that reads from the channel and invokes the handler + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var consumerTask = Task.Run(async () => + { + try + { + await foreach (var msg in channel.Reader.ReadAllAsync(cts.Token).ConfigureAwait(false)) + { + await handler(msg, cts.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + }, cts.Token); + + IAsyncDisposable subscription = new Subscription(() => + { + entries.TryRemove(id, out _); + channel.Writer.TryComplete(); + cts.Cancel(); + cts.Dispose(); + return ValueTask.CompletedTask; + }); + + return Task.FromResult(subscription); + } + + public void Dispose() + { + foreach (var topicEntries in _subscriptions.Values) + { + foreach (var entry in topicEntries.Values) + entry.Writer.TryComplete(); + topicEntries.Clear(); + } + _subscriptions.Clear(); + } + + private sealed class SubscriptionEntry(ChannelWriter writer) + { + public ChannelWriter Writer => writer; + } + + private sealed class Subscription(Func onDispose) : IAsyncDisposable + { + public ValueTask DisposeAsync() => onDispose(); + } +} diff --git a/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs b/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs new file mode 100644 index 00000000..3ac7f62b --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs @@ -0,0 +1,197 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace Foundatio.Mediator.Distributed; + +/// +/// In-memory backed by . +/// Intended for development and testing. Does not support visibility timeouts +/// or dead-letter semantics β€” and +/// are no-ops, and re-enqueues the message immediately. +/// +public sealed class InMemoryQueueClient : IQueueClient +{ + private readonly ConcurrentDictionary> _channels = new(); + private readonly ConcurrentDictionary> _deadLetterChannels = new(); + private readonly TimeProvider _timeProvider; + + public InMemoryQueueClient(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default) + { + var channel = GetOrCreateChannel(queueName); + var internalEntry = new InMemoryEntry + { + Id = Guid.NewGuid().ToString("N"), + Body = entry.Body, + Headers = entry.Headers != null ? new Dictionary(entry.Headers) : new(), + DequeueCount = 0, + EnqueuedAt = _timeProvider.GetUtcNow() + }; + + return channel.Writer.WriteAsync(internalEntry, cancellationToken).AsTask(); + } + + public async Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) + { + foreach (var entry in entries) + await SendAsync(queueName, entry, cancellationToken).ConfigureAwait(false); + } + + public async Task> ReceiveAsync(string queueName, int maxCount, CancellationToken cancellationToken = default) + { + var channel = GetOrCreateChannel(queueName); + var results = new List(maxCount); + + // Wait for at least one message + InMemoryEntry first; + try + { + first = await channel.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return results; + } + + var now = _timeProvider.GetUtcNow(); + first.DequeueCount++; + results.Add(ToQueueMessage(first, queueName, now)); + + // Try to read more without waiting + while (results.Count < maxCount && channel.Reader.TryRead(out var entry)) + { + entry.DequeueCount++; + results.Add(ToQueueMessage(entry, queueName, now)); + } + + return results; + } + + public Task CompleteAsync(QueueMessage message, CancellationToken cancellationToken = default) + => Task.CompletedTask; // Already consumed from channel + + public async Task AbandonAsync(QueueMessage message, TimeSpan delay = default, CancellationToken cancellationToken = default) + { + if (delay > TimeSpan.Zero) + await Task.Delay(delay, _timeProvider, cancellationToken).ConfigureAwait(false); + + // Re-enqueue with the existing dequeue count (already incremented) + var channel = GetOrCreateChannel(message.QueueName); + var entry = new InMemoryEntry + { + Id = message.Id, + Body = message.Body, + Headers = new Dictionary(message.Headers), + DequeueCount = message.DequeueCount, + EnqueuedAt = message.EnqueuedAt + }; + + await channel.Writer.WriteAsync(entry, cancellationToken).ConfigureAwait(false); + } + + public Task RenewTimeoutAsync(QueueMessage message, TimeSpan extension, CancellationToken cancellationToken = default) + => Task.CompletedTask; // No visibility timeout concept in-memory + + public Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default) + { + var dlqChannel = GetOrCreateDeadLetterChannel(message.QueueName); + var entry = new InMemoryEntry + { + Id = message.Id, + Body = message.Body, + Headers = new Dictionary(message.Headers) + { + [MessageHeaders.DeadLetterReason] = reason, + [MessageHeaders.DeadLetteredAt] = _timeProvider.GetUtcNow().ToString("O"), + [MessageHeaders.OriginalQueueName] = message.QueueName, + [MessageHeaders.DeadLetterDequeueCount] = message.DequeueCount.ToString() + }, + DequeueCount = message.DequeueCount, + EnqueuedAt = message.EnqueuedAt + }; + + return dlqChannel.Writer.WriteAsync(entry, cancellationToken).AsTask(); + } + + /// + /// Gets the number of messages in the dead-letter queue for the specified queue. + /// Intended for testing assertions. + /// + public int GetDeadLetterCount(string queueName) + { + if (!_deadLetterChannels.TryGetValue(queueName, out var channel)) + return 0; + + return channel.Reader.Count; + } + + /// + /// Reads all messages currently in the dead-letter queue for the specified queue. + /// Messages are consumed (removed) from the DLQ. Intended for testing assertions. + /// + public IReadOnlyList DrainDeadLetterMessages(string queueName) + { + if (!_deadLetterChannels.TryGetValue(queueName, out var channel)) + return []; + + var messages = new List(); + var now = _timeProvider.GetUtcNow(); + while (channel.Reader.TryRead(out var entry)) + { + messages.Add(ToQueueMessage(entry, $"{queueName}-dead-letter", now)); + } + + return messages; + } + + private Channel GetOrCreateChannel(string queueName) + => _channels.GetOrAdd(queueName, _ => Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = false, SingleWriter = false })); + + private Channel GetOrCreateDeadLetterChannel(string queueName) + => _deadLetterChannels.GetOrAdd(queueName, _ => Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = false, SingleWriter = false })); + + /// + public Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) + { + int activeCount = 0; + if (_channels.TryGetValue(queueName, out var channel)) + activeCount = channel.Reader.Count; + + int deadLetterCount = 0; + if (_deadLetterChannels.TryGetValue(queueName, out var dlqChannel)) + deadLetterCount = dlqChannel.Reader.Count; + + return Task.FromResult(new QueueStats + { + QueueName = queueName, + ActiveCount = activeCount, + DeadLetterCount = deadLetterCount + }); + } + + private static QueueMessage ToQueueMessage(InMemoryEntry entry, string queueName, DateTimeOffset dequeuedAt) => new() + { + Id = entry.Id, + Body = entry.Body, + Headers = entry.Headers, + QueueName = queueName, + DequeueCount = entry.DequeueCount, + EnqueuedAt = entry.EnqueuedAt, + DequeuedAt = dequeuedAt + }; + + private sealed class InMemoryEntry + { + public required string Id { get; init; } + public required ReadOnlyMemory Body { get; init; } + public required Dictionary Headers { get; init; } + public int DequeueCount { get; set; } + public DateTimeOffset EnqueuedAt { get; init; } + } +} diff --git a/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs new file mode 100644 index 00000000..db2e972c --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs @@ -0,0 +1,153 @@ +using System.Collections.Concurrent; + +namespace Foundatio.Mediator.Distributed; + +/// +/// In-memory implementation of . +/// Suitable for development, testing, and single-node deployments. +/// Expired entries are lazily cleaned up on access. +/// +public sealed class InMemoryQueueJobStateStore : IQueueJobStateStore +{ + private readonly ConcurrentDictionary _jobs = new(); + private readonly ConcurrentDictionary _cancellations = new(); + private readonly ConcurrentDictionary> _counters = new(); + private readonly TimeProvider _timeProvider; + private int _accessCount; + + public InMemoryQueueJobStateStore(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var now = _timeProvider.GetUtcNow(); + var expiresAt = expiry.HasValue ? now + expiry.Value : DateTimeOffset.MaxValue; + + _jobs[state.JobId] = new JobEntry(state, expiresAt); + + CleanupIfNeeded(); + + return Task.CompletedTask; + } + + public Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default) + { + if (_jobs.TryGetValue(jobId, out var entry) && !IsExpired(entry)) + return Task.FromResult(entry.State); + + // Remove expired entry on access + if (entry is not null) + { + _jobs.TryRemove(jobId, out _); + _cancellations.TryRemove(jobId, out _); + } + + return Task.FromResult(null); + } + + public Task> GetJobsByQueueAsync(string queueName, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + { + var now = _timeProvider.GetUtcNow(); + var results = _jobs.Values + .Where(e => !IsExpired(e, now) && string.Equals(e.State.QueueName, queueName, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(e => e.State.CreatedUtc) + .Skip(skip) + .Take(take) + .Select(e => e.State) + .ToList(); + + return Task.FromResult>(results); + } + + public Task RequestCancellationAsync(string jobId, CancellationToken cancellationToken = default) + { + if (!_jobs.TryGetValue(jobId, out var entry) || IsExpired(entry)) + return Task.FromResult(false); + + // Only allow cancellation for non-terminal states + if (entry.State.Status is QueueJobStatus.Completed or QueueJobStatus.Failed or QueueJobStatus.Cancelled) + return Task.FromResult(false); + + _cancellations[jobId] = true; + return Task.FromResult(true); + } + + public Task IsCancellationRequestedAsync(string jobId, CancellationToken cancellationToken = default) + { + return Task.FromResult(_cancellations.ContainsKey(jobId)); + } + + public Task RemoveJobStateAsync(string jobId, CancellationToken cancellationToken = default) + { + _jobs.TryRemove(jobId, out _); + _cancellations.TryRemove(jobId, out _); + return Task.CompletedTask; + } + + public Task IncrementCounterAsync(string queueName, string counterName, long value = 1, CancellationToken cancellationToken = default) + { + var queueCounters = _counters.GetOrAdd(queueName, _ => new ConcurrentDictionary()); + queueCounters.AddOrUpdate(counterName, value, (_, existing) => existing + value); + return Task.CompletedTask; + } + + public Task> GetCountersAsync(string queueName, CancellationToken cancellationToken = default) + { + if (_counters.TryGetValue(queueName, out var queueCounters)) + return Task.FromResult>(new Dictionary(queueCounters)); + + return Task.FromResult>(new Dictionary()); + } + + public Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + { + var now = _timeProvider.GetUtcNow(); + var statusSet = new HashSet(statuses); + var results = _jobs.Values + .Where(e => !IsExpired(e, now) + && string.Equals(e.State.QueueName, queueName, StringComparison.OrdinalIgnoreCase) + && statusSet.Contains(e.State.Status)) + .OrderByDescending(e => e.State.CreatedUtc) + .Skip(skip) + .Take(take) + .Select(e => e.State) + .ToList(); + + return Task.FromResult>(results); + } + + public Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + { + var now = _timeProvider.GetUtcNow(); + var count = _jobs.Values.Count(e => !IsExpired(e, now) + && string.Equals(e.State.QueueName, queueName, StringComparison.OrdinalIgnoreCase) + && e.State.Status == status); + + return Task.FromResult((long)count); + } + + private bool IsExpired(JobEntry entry) => IsExpired(entry, _timeProvider.GetUtcNow()); + + private static bool IsExpired(JobEntry entry, DateTimeOffset now) => now >= entry.ExpiresAt; + + private void CleanupIfNeeded() + { + // Run cleanup every 100 writes to avoid accumulating expired entries + if (Interlocked.Increment(ref _accessCount) % 100 != 0) + return; + + var now = _timeProvider.GetUtcNow(); + foreach (var kvp in _jobs) + { + if (now >= kvp.Value.ExpiresAt) + { + _jobs.TryRemove(kvp.Key, out _); + _cancellations.TryRemove(kvp.Key, out _); + } + } + } + + private sealed record JobEntry(QueueJobState State, DateTimeOffset ExpiresAt); +} diff --git a/src/Foundatio.Mediator.Distributed/MessageHeaders.cs b/src/Foundatio.Mediator.Distributed/MessageHeaders.cs new file mode 100644 index 00000000..af1199c2 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/MessageHeaders.cs @@ -0,0 +1,70 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Well-known header keys used by the distributed messaging infrastructure. +/// These map to transport-native message attributes (SQS MessageAttributes, +/// RabbitMQ headers, etc.). +/// +public static class MessageHeaders +{ + /// + /// The assembly-qualified type name of the message, used for deserialization. + /// + public const string MessageType = "fm-message-type"; + + /// + /// Optional correlation identifier for tracing a message through the system. + /// + public const string CorrelationId = "fm-correlation-id"; + + /// + /// ISO 8601 timestamp of when the message was enqueued. + /// + public const string EnqueuedAt = "fm-enqueued-at"; + + /// + /// The unique identifier of the host that originally published the notification. + /// Used to prevent a node from re-processing its own message. + /// + public const string OriginHostId = "fm-origin-host-id"; + + /// + /// ISO 8601 timestamp of when the notification was published to the bus. + /// + public const string PublishedAt = "fm-published-at"; + + /// + /// W3C traceparent header for distributed trace context propagation. + /// + public const string TraceParent = "traceparent"; + + /// + /// W3C tracestate header for vendor-specific trace context. + /// + public const string TraceState = "tracestate"; + + /// + /// The reason a message was moved to the dead-letter queue. + /// + public const string DeadLetterReason = "fm-dead-letter-reason"; + + /// + /// ISO 8601 timestamp of when the message was dead-lettered. + /// + public const string DeadLetteredAt = "fm-dead-lettered-at"; + + /// + /// The original queue name before the message was dead-lettered. + /// + public const string OriginalQueueName = "fm-original-queue-name"; + + /// + /// The number of times the message was dequeued before being dead-lettered. + /// + public const string DeadLetterDequeueCount = "fm-dead-letter-dequeue-count"; + + /// + /// The unique job identifier assigned at enqueue time for progress tracking. + /// + public const string JobId = "fm-job-id"; +} diff --git a/src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs b/src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs new file mode 100644 index 00000000..f859de4b --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs @@ -0,0 +1,72 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Gets a list of all registered queue workers with their configuration and runtime stats. +/// +public record GetQueueWorkers; + +/// +/// Gets a specific queue worker by queue name, including queue stats. +/// +public record GetQueueWorker(string QueueName); + +/// +/// Gets tracked jobs for a specific queue. +/// +public record GetQueueJobs(string QueueName, int Skip = 0, int Take = 50); + +/// +/// Gets a dashboard view of jobs for a queue: queued count, active jobs, and recent terminal jobs. +/// +public record GetQueueJobDashboard(string QueueName, int? RecentTerminalCount = 20); + +/// +/// Gets the state of a specific tracked job. +/// +public record GetQueueJob(string JobId); + +/// +/// Requests cancellation of a tracked job. +/// +public record CancelQueueJob(string JobId); + +/// +/// Summary of a queue worker's configuration and runtime statistics. +/// +public record QueueWorkerSummary +{ + public required string QueueName { get; init; } + public required string MessageTypeName { get; init; } + public int Concurrency { get; init; } + public int PrefetchCount { get; init; } + public int MaxRetries { get; init; } + public required string VisibilityTimeout { get; init; } + public string? Group { get; init; } + public required string RetryPolicy { get; init; } + public bool TrackProgress { get; init; } + public bool IsRunning { get; init; } + public long MessagesProcessed { get; init; } + public long MessagesFailed { get; init; } + public long MessagesDeadLettered { get; init; } + public QueueStats? Stats { get; init; } +} + +/// +/// Result for a cancellation request. +/// +public record QueueJobCancellationResult(string JobId, bool CancellationRequested); + +/// +/// Dashboard view of jobs for a queue, partitioned by lifecycle phase. +/// +public record QueueJobDashboardView +{ + /// Number of jobs waiting in the queue (not yet picked up). + public long QueuedCount { get; init; } + + /// Jobs currently being processed. + public required IReadOnlyList ActiveJobs { get; init; } + + /// Recently completed, failed, or cancelled jobs. + public required IReadOnlyList RecentJobs { get; init; } +} diff --git a/src/Foundatio.Mediator.Distributed/PubSubMessage.cs b/src/Foundatio.Mediator.Distributed/PubSubMessage.cs new file mode 100644 index 00000000..d95f7748 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/PubSubMessage.cs @@ -0,0 +1,17 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// A message received from the pub/sub client. +/// +public sealed class PubSubMessage +{ + /// + /// The serialized message body. + /// + public required ReadOnlyMemory Body { get; init; } + + /// + /// Transport headers / metadata. + /// + public required IReadOnlyDictionary Headers { get; init; } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueAttribute.cs b/src/Foundatio.Mediator.Distributed/QueueAttribute.cs new file mode 100644 index 00000000..1884db45 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueAttribute.cs @@ -0,0 +1,88 @@ +using Foundatio.Mediator; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Marks a handler class or method for queue-based processing. +/// When applied, invocations via mediator.InvokeAsync() will serialize the message +/// and send it to a queue for asynchronous processing instead of executing the handler inline. +/// +/// +/// +/// [Queue(Concurrency = 3)] +/// public class OrderProcessingHandler +/// { +/// public async Task<Result> HandleAsync( +/// ProcessOrder message, +/// CancellationToken ct) +/// { +/// // ... do work ... +/// return Result.Success(); +/// } +/// } +/// +/// +[UseMiddleware(typeof(QueueMiddleware))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class QueueAttribute : Attribute +{ + /// + /// Override the queue name. Defaults to the message type name. + /// + public string? QueueName { get; set; } + + /// + /// Maximum number of retry attempts before dead-lettering. Default is 2. + /// Total attempts = MaxRetries + 1 (initial attempt + retries). + /// + public int MaxRetries { get; set; } = 2; + + /// + /// Work item timeout as a TimeSpan string (e.g., "00:05:00"). + /// If a message is not completed within this duration, it is automatically abandoned. + /// Default is 5 minutes. + /// + public string? Timeout { get; set; } + + /// + /// Number of concurrent consumer tasks processing this queue. Default is 1. + /// + public int Concurrency { get; set; } = 1; + + /// + /// Number of messages to fetch per receive batch. Default is 1. + /// Higher values reduce round-trips to the transport at the cost of larger working sets. + /// + public int PrefetchCount { get; set; } = 1; + + /// + /// Queue group name for selective hosting. When set, only workers configured + /// for the matching group will process messages from this queue. + /// + public string? Group { get; set; } + + /// + /// When true, the worker automatically completes the message on success + /// and abandons it on exception. Default is true. + /// + public bool AutoComplete { get; set; } = true; + + /// + /// The retry delay strategy for failed messages. Default is . + /// + public QueueRetryPolicy RetryPolicy { get; set; } = QueueRetryPolicy.Exponential; + + /// + /// The base delay between retries as a TimeSpan string (e.g., "00:00:05"). + /// For , this is the constant delay. + /// For , this is the initial delay that doubles on each retry. + /// Default is 5 seconds. + /// + public string? RetryDelay { get; set; } + + /// + /// When true, the job's progress and state are tracked via . + /// Enables progress reporting, cancellation, and dashboard visibility. Default is false. + /// + public bool TrackProgress { get; set; } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueContext.cs b/src/Foundatio.Mediator.Distributed/QueueContext.cs new file mode 100644 index 00000000..0f167010 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueContext.cs @@ -0,0 +1,120 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Provides queue-specific context to handler methods during message processing. +/// Injected via so handlers can report progress and +/// renew message timeouts for long-running work. +/// +/// +/// Handlers that accept a parameter will receive it +/// automatically via CallContext injection when processing a queued message: +/// +/// [Queue(Concurrency = 3)] +/// public class LongRunningHandler +/// { +/// public async Task HandleAsync( +/// ProcessLargeFile message, +/// QueueContext queueContext, +/// CancellationToken ct) +/// { +/// foreach (var chunk in GetChunks(message)) +/// { +/// await ProcessChunkAsync(chunk, ct); +/// await queueContext.RenewTimeoutAsync(TimeSpan.FromMinutes(5), ct); +/// } +/// } +/// } +/// +/// +public class QueueContext +{ + /// + /// The name of the queue this message was received from. + /// + public string QueueName { get; init; } = string.Empty; + + /// + /// The message type being processed. + /// + public Type? MessageType { get; init; } + + /// + /// The number of times this message has been dequeued (including the current attempt). + /// Useful for detecting poison messages or implementing backoff strategies. + /// + public int DequeueCount { get; init; } + + /// + /// The maximum number of retries configured for this queue. + /// After MaxRetries + 1 total attempts, the message will be dead-lettered. + /// + public int MaxRetries { get; init; } + + /// + /// When the message was originally enqueued. + /// + public DateTimeOffset EnqueuedAt { get; init; } + + /// + /// The unique job identifier for progress tracking, or null if tracking is not enabled. + /// + public string? JobId { get; init; } + + /// + /// Delegate invoked by to signal that the handler + /// is still actively working. This acts as a heartbeat keep-alive that extends the + /// message visibility by the configured timeout. Set by the worker infrastructure. + /// + public Func? OnReportProgress { get; init; } + + /// + /// Delegate invoked by + /// to update progress percentage and message in the state store. + /// Set by the worker infrastructure when progress tracking is enabled. + /// + public Func? OnReportDetailedProgress { get; init; } + + /// + /// Delegate invoked by to extend the message lock + /// or visibility timeout by a specific duration. Set by the worker infrastructure. + /// + public Func? OnRenewTimeout { get; init; } + + /// + /// Reports that the handler is still actively processing the message. + /// For transports that support it, this extends the visibility timeout + /// by the configured default duration, preventing the message from being + /// redelivered during long-running operations. + /// + public Task ReportProgressAsync(CancellationToken cancellationToken = default) + => OnReportProgress?.Invoke(cancellationToken) ?? Task.CompletedTask; + + /// + /// Reports progress with a percentage and optional message. + /// When progress tracking is enabled, this updates the job state store + /// and checks for cancellation. If cancellation has been requested, + /// an is thrown. + /// Also acts as a heartbeat to extend the message visibility timeout. + /// + /// Progress percentage (0–100). + /// Optional description of current work. + /// A cancellation token. + public async Task ReportProgressAsync(int progressPercent, string? message = null, CancellationToken cancellationToken = default) + { + // Always renew the visibility timeout as a heartbeat + if (OnReportProgress is not null) + await OnReportProgress(cancellationToken).ConfigureAwait(false); + + // Update state store and check for cancellation + if (OnReportDetailedProgress is not null) + await OnReportDetailedProgress(progressPercent, message, cancellationToken).ConfigureAwait(false); + } + + /// + /// Extends the message lock or visibility timeout by the specified duration. + /// Use this for long-running handlers to prevent the message from being + /// redelivered to another consumer. + /// + public Task RenewTimeoutAsync(TimeSpan extension, CancellationToken cancellationToken = default) + => OnRenewTimeout?.Invoke(extension, cancellationToken) ?? Task.CompletedTask; +} diff --git a/src/Foundatio.Mediator.Distributed/QueueEntry.cs b/src/Foundatio.Mediator.Distributed/QueueEntry.cs new file mode 100644 index 00000000..aef7854d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueEntry.cs @@ -0,0 +1,21 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Represents an outbound message to be sent to a queue. +/// The contains the pure serialized message payload. +/// Metadata (type discriminator, correlation id, etc.) is carried as +/// , which map to transport-native message attributes +/// (SQS MessageAttributes, RabbitMQ headers, etc.). +/// +public sealed class QueueEntry +{ + /// + /// The serialized message body. + /// + public required ReadOnlyMemory Body { get; init; } + + /// + /// Optional metadata headers. Well-known keys are defined in . + /// + public Dictionary? Headers { get; init; } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueJobState.cs b/src/Foundatio.Mediator.Distributed/QueueJobState.cs new file mode 100644 index 00000000..9e297583 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueJobState.cs @@ -0,0 +1,93 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Represents the current state of a queue job being tracked. +/// +public sealed class QueueJobState +{ + /// + /// The unique identifier for this job, generated at enqueue time. + /// + public required string JobId { get; init; } + + /// + /// The name of the queue this job was sent to. + /// + public required string QueueName { get; init; } + + /// + /// The full name of the message type being processed. + /// + public string MessageType { get; init; } = string.Empty; + + /// + /// The current status of the job. + /// + public QueueJobStatus Status { get; set; } = QueueJobStatus.Queued; + + /// + /// Progress percentage (0–100). Updated by the handler via . + /// + public int Progress { get; set; } + + /// + /// Optional message describing what the job is currently doing. + /// + public string? ProgressMessage { get; set; } + + /// + /// When the job was created (enqueued). + /// + public DateTimeOffset CreatedUtc { get; init; } + + /// + /// When the worker started processing the job. + /// + public DateTimeOffset? StartedUtc { get; set; } + + /// + /// When the job reached a terminal state (Completed, Failed, or Cancelled). + /// + public DateTimeOffset? CompletedUtc { get; set; } + + /// + /// Error message when the job has failed. + /// + public string? ErrorMessage { get; set; } + + /// + /// The last time this state was updated. + /// + public DateTimeOffset LastUpdatedUtc { get; set; } +} + +/// +/// The lifecycle status of a queue job. +/// +public enum QueueJobStatus +{ + /// + /// The message has been enqueued but not yet picked up by a worker. + /// + Queued = 0, + + /// + /// A worker is currently processing the message. + /// + Processing = 1, + + /// + /// The handler completed successfully. + /// + Completed = 2, + + /// + /// The handler threw an exception or the message was dead-lettered. + /// + Failed = 3, + + /// + /// The job was cancelled via . + /// + Cancelled = 4 +} diff --git a/src/Foundatio.Mediator.Distributed/QueueMessage.cs b/src/Foundatio.Mediator.Distributed/QueueMessage.cs new file mode 100644 index 00000000..8a0b0b1a --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueMessage.cs @@ -0,0 +1,50 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Represents a message that has been dequeued from a queue. +/// Contains the message body, headers, and metadata for tracking and lifecycle management. +/// +public sealed class QueueMessage +{ + /// + /// Unique identifier for this message instance, assigned by the transport. + /// + public required string Id { get; init; } + + /// + /// The serialized message body. + /// + public required ReadOnlyMemory Body { get; init; } + + /// + /// Message headers / metadata. Well-known keys are defined in . + /// + public required IReadOnlyDictionary Headers { get; init; } + + /// + /// The name of the queue this message was received from. + /// + public required string QueueName { get; init; } + + /// + /// The number of times this message has been dequeued (including the current attempt). + /// Useful for detecting poison messages or implementing backoff strategies. + /// + public int DequeueCount { get; init; } + + /// + /// When the message was originally enqueued. + /// + public DateTimeOffset EnqueuedAt { get; init; } + + /// + /// When the message was dequeued for the current processing attempt. + /// + public DateTimeOffset DequeuedAt { get; init; } + + /// + /// Transport-specific native message handle (e.g., SQS Message object). + /// Used internally by implementations for lifecycle operations. + /// + public object? NativeMessage { get; init; } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs new file mode 100644 index 00000000..394f8e26 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Middleware that intercepts handler invocations for -decorated handlers. +/// +/// +/// +/// On the enqueue path (normal caller), this middleware serializes the message +/// and sends it to the queue via . +/// The call returns immediately with . +/// +/// +/// On the process path (when dispatches a dequeued message), +/// the presence of a in +/// signals that this is a processing invocation. The middleware passes through to next() +/// so the full pipeline (logging, validation, auth, etc.) executes before the handler. +/// +/// +[Middleware(Order = -100, ExplicitOnly = true)] +public class QueueMiddleware +{ + private readonly IQueueClient _client; + private readonly IQueueJobStateStore? _stateStore; + private readonly JsonSerializerOptions _jsonOptions; + private readonly TimeProvider _timeProvider; + + public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, IQueueJobStateStore? stateStore = null, TimeProvider? timeProvider = null) + { + _client = client; + _stateStore = stateStore; + _jsonOptions = options?.JsonSerializerOptions ?? JsonSerializerOptions.Default; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async ValueTask ExecuteAsync( + object message, + HandlerExecutionDelegate next, + HandlerExecutionInfo handlerInfo, + CallContext? callContext) + { + // Process path: QueueContext in CallContext signals we're processing from the queue + if (callContext?.TryGet(out _) == true) + return await next().ConfigureAwait(false); + + // Inbound notification path: message arrived from the distributed bus. + // The originating node already enqueued to the shared queue, so skip re-enqueueing. + if (DistributedContext.IsNotification) + return await next().ConfigureAwait(false); + + // Enqueue path: serialize and send to the queue + var messageType = message.GetType(); + var body = JsonSerializer.SerializeToUtf8Bytes(message, messageType, _jsonOptions); + var queueName = GetQueueName(handlerInfo, messageType); + + var headers = new Dictionary + { + [MessageHeaders.MessageType] = messageType.AssemblyQualifiedName!, + [MessageHeaders.EnqueuedAt] = _timeProvider.GetUtcNow().ToString("O") + }; + + // Propagate W3C trace context so queue consumers appear in the same trace + var currentActivity = Activity.Current; + if (currentActivity is not null) + { + headers[MessageHeaders.TraceParent] = currentActivity.Id!; + if (currentActivity.TraceStateString is { Length: > 0 } traceState) + headers[MessageHeaders.TraceState] = traceState; + } + + // Generate job ID and track initial state when progress tracking is enabled + string? jobId = null; + var trackProgress = IsTrackProgressEnabled(handlerInfo); + if (trackProgress && _stateStore is not null) + { + jobId = Guid.NewGuid().ToString("N"); + headers[MessageHeaders.JobId] = jobId; + + var now = _timeProvider.GetUtcNow(); + var jobState = new QueueJobState + { + JobId = jobId, + QueueName = queueName, + MessageType = messageType.FullName ?? messageType.Name, + Status = QueueJobStatus.Queued, + CreatedUtc = now, + LastUpdatedUtc = now + }; + + await _stateStore.SetJobStateAsync(jobState, cancellationToken: default).ConfigureAwait(false); + } + + var entry = new QueueEntry + { + Body = body, + Headers = headers + }; + + await _client.SendAsync(queueName, entry, default).ConfigureAwait(false); + + if (jobId is not null) + return Result.Accepted(jobId); + + return Result.Accepted("Message queued"); + } + + private static string GetQueueName(HandlerExecutionInfo handlerInfo, Type messageType) + { + var queueAttr = handlerInfo.HandlerType + .GetCustomAttributes(typeof(QueueAttribute), true) + .OfType() + .FirstOrDefault(); + + return !string.IsNullOrWhiteSpace(queueAttr?.QueueName) + ? queueAttr!.QueueName! + : messageType.Name; + } + + private static bool IsTrackProgressEnabled(HandlerExecutionInfo handlerInfo) + { + var queueAttr = handlerInfo.HandlerType + .GetCustomAttributes(typeof(QueueAttribute), true) + .OfType() + .FirstOrDefault(); + + return queueAttr?.TrackProgress == true; + } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueRetryPolicy.cs b/src/Foundatio.Mediator.Distributed/QueueRetryPolicy.cs new file mode 100644 index 00000000..cd094eb6 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueRetryPolicy.cs @@ -0,0 +1,23 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Defines the retry delay strategy for failed queue messages. +/// +public enum QueueRetryPolicy +{ + /// + /// No delay between retries. Failed messages are immediately redelivered. + /// + None, + + /// + /// Constant delay between retries. Each retry waits the same configured base delay. + /// + Fixed, + + /// + /// Exponential backoff between retries. Each successive retry doubles the delay. + /// A proportional jitter (Β±10%) is added to prevent thundering herd. + /// + Exponential +} diff --git a/src/Foundatio.Mediator.Distributed/QueueStats.cs b/src/Foundatio.Mediator.Distributed/QueueStats.cs new file mode 100644 index 00000000..85ae7d9a --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueStats.cs @@ -0,0 +1,33 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Transport-level statistics for a single queue. +/// +public sealed class QueueStats +{ + /// + /// An empty stats instance with all counts at zero. + /// + public static QueueStats Empty { get; } = new() { QueueName = string.Empty }; + + /// + /// The name of the queue. + /// + public required string QueueName { get; init; } + + /// + /// Approximate number of messages available for retrieval. + /// + public long ActiveCount { get; init; } + + /// + /// Approximate number of messages in the dead-letter queue. + /// + public long DeadLetterCount { get; init; } + + /// + /// Approximate number of messages currently being processed (in-flight). + /// Not all transports support this metric. + /// + public long InFlightCount { get; init; } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueWorker.cs b/src/Foundatio.Mediator.Distributed/QueueWorker.cs new file mode 100644 index 00000000..600095fc --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorker.cs @@ -0,0 +1,484 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Background service that processes messages from a single queue. +/// Runs a receive loop that pulls batches from into a bounded +/// , then dispatches to N concurrent consumer tasks that +/// deserialize and invoke the handler via . +/// +public sealed class QueueWorker : BackgroundService +{ + private readonly IQueueClient _client; + private readonly IServiceScopeFactory _scopeFactory; + private readonly QueueWorkerOptions _options; + private readonly QueueWorkerInfo? _workerInfo; + private readonly IQueueJobStateStore? _stateStore; + private readonly JsonSerializerOptions _jsonOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private static readonly Random s_jitterRandom = new(); + private static readonly TimeSpan s_defaultStateExpiry = TimeSpan.FromHours(24); + + public QueueWorker( + IQueueClient client, + IServiceScopeFactory scopeFactory, + QueueWorkerOptions options, + DistributedOptions? distributedOptions, + ILogger logger, + QueueWorkerInfo? workerInfo = null, + IQueueJobStateStore? stateStore = null, + TimeProvider? timeProvider = null) + { + _client = client; + _scopeFactory = scopeFactory; + _options = options; + _workerInfo = workerInfo; + _stateStore = stateStore; + _jsonOptions = distributedOptions?.JsonSerializerOptions ?? JsonSerializerOptions.Default; + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (_workerInfo is not null) + _workerInfo._isRunning = true; + + try + { + _logger.LogInformation("Queue worker starting for {QueueName} (concurrency={Concurrency}, prefetch={PrefetchCount})", + _options.QueueName, _options.Concurrency, _options.PrefetchCount); + + // Bounded channel acts as the bridge between receive loop and consumer tasks. + // Capacity = concurrency + prefetch so the receive loop can stay ahead of consumers. + var channel = Channel.CreateBounded(new BoundedChannelOptions(_options.Concurrency + _options.PrefetchCount) + { + SingleWriter = true, + SingleReader = _options.Concurrency == 1, + FullMode = BoundedChannelFullMode.Wait + }); + + // Start consumer tasks + var consumers = new Task[_options.Concurrency]; + for (int i = 0; i < _options.Concurrency; i++) + consumers[i] = RunConsumerAsync(channel.Reader, stoppingToken); + + // Receive loop + try + { + await RunReceiveLoopAsync(channel.Writer, stoppingToken).ConfigureAwait(false); + } + finally + { + channel.Writer.Complete(); + await Task.WhenAll(consumers).ConfigureAwait(false); + } + + _logger.LogInformation("Queue worker stopped for {QueueName}", _options.QueueName); + } + finally + { + if (_workerInfo is not null) + _workerInfo._isRunning = false; + } + } + + private async Task RunReceiveLoopAsync(ChannelWriter writer, CancellationToken stoppingToken) + { + int consecutiveErrors = 0; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var messages = await _client.ReceiveAsync(_options.QueueName, _options.PrefetchCount, stoppingToken).ConfigureAwait(false); + consecutiveErrors = 0; + + foreach (var message in messages) + await writer.WriteAsync(message, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + consecutiveErrors++; + var delay = TimeSpan.FromSeconds(Math.Min(Math.Pow(2, consecutiveErrors - 1), 30)); + _logger.LogError(ex, "Error receiving messages from {QueueName}, retrying in {Delay}...", _options.QueueName, delay); + try + { + await Task.Delay(delay, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + } + + private async Task RunConsumerAsync(ChannelReader reader, CancellationToken stoppingToken) + { + await foreach (var message in reader.ReadAllAsync(stoppingToken).ConfigureAwait(false)) + { + await ProcessMessageAsync(message, stoppingToken).ConfigureAwait(false); + } + } + + private async Task ProcessMessageAsync(QueueMessage message, CancellationToken stoppingToken) + { + // Restore trace context from the enqueuing operation + ActivityContext parentContext = default; + if (message.Headers.TryGetValue(MessageHeaders.TraceParent, out var traceParent) + && ActivityContext.TryParse(traceParent, message.Headers.GetValueOrDefault(MessageHeaders.TraceState), out var parsed)) + { + parentContext = parsed; + } + + using var activity = MediatorActivitySource.Instance.StartActivity( + $"Process {_options.QueueName}", + ActivityKind.Consumer, + parentContext); + + // Dead-letter check: if the message has exceeded the retry limit, move it to the DLQ + if (_options.MaxRetries >= 0 && message.DequeueCount > _options.MaxRetries + 1) + { + _logger.LogWarning( + "Message {MessageId} on {QueueName} exceeded max retries ({DequeueCount}/{MaxRetries}), dead-lettering", + message.Id, _options.QueueName, message.DequeueCount, _options.MaxRetries); + + activity?.SetTag("messaging.dead_letter", true); + activity?.SetTag("messaging.dead_letter.reason", "MaxRetriesExceeded"); + + if (_workerInfo is not null) + Interlocked.Increment(ref _workerInfo._messagesDeadLettered); + + if (_stateStore is not null) + await _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken).ConfigureAwait(false); + + await DeadLetterAsync(message, $"Exceeded max retries ({_options.MaxRetries})").ConfigureAwait(false); + return; + } + + // Extract job tracking info + string? jobId = null; + var trackProgress = _options.TrackProgress && _stateStore is not null; + if (trackProgress) + message.Headers.TryGetValue(MessageHeaders.JobId, out jobId); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + timeoutCts.CancelAfter(_options.VisibilityTimeout); + + // Start cancellation polling if tracking is enabled and we have a job ID + Task? cancellationPollTask = null; + if (trackProgress && jobId is not null) + cancellationPollTask = PollForCancellationAsync(jobId, timeoutCts, stoppingToken); + + var linkedToken = timeoutCts.Token; + + try + { + // Update state to Processing + if (trackProgress && jobId is not null) + { + var now = _timeProvider.GetUtcNow(); + var state = await _stateStore!.GetJobStateAsync(jobId, stoppingToken).ConfigureAwait(false); + if (state is not null) + { + state.Status = QueueJobStatus.Processing; + state.StartedUtc = now; + state.LastUpdatedUtc = now; + await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, stoppingToken).ConfigureAwait(false); + } + } + + // Deserialize body to typed message + var typedMessage = JsonSerializer.Deserialize(message.Body.Span, _options.MessageType, _jsonOptions); + if (typedMessage is null) + { + _logger.LogWarning("Failed to deserialize message {MessageId} from {QueueName} as {MessageType}", + message.Id, _options.QueueName, _options.MessageType.Name); + + await UpdateJobStateFailed(jobId, $"Deserialization returned null for type {_options.MessageType.Name}", stoppingToken).ConfigureAwait(false); + await DeadLetterAsync(message, $"Deserialization returned null for type {_options.MessageType.Name}").ConfigureAwait(false); + return; + } + + // Build QueueContext with delegates wired to IQueueClient + var queueContext = new QueueContext + { + QueueName = _options.QueueName, + MessageType = _options.MessageType, + DequeueCount = message.DequeueCount, + MaxRetries = _options.MaxRetries, + EnqueuedAt = message.EnqueuedAt, + JobId = jobId, + OnRenewTimeout = (extension, ct) => _client.RenewTimeoutAsync(message, extension, ct), + OnReportProgress = ct => _client.RenewTimeoutAsync(message, _options.VisibilityTimeout, ct), + OnReportDetailedProgress = trackProgress && jobId is not null + ? (percent, msg, ct) => UpdateJobProgressAsync(jobId, percent, msg, ct) + : null + }; + + using var callContext = CallContext.Rent().Set(queueContext); + + // Create a scope so scoped services (including IMediator) are resolved correctly + await using var scope = _scopeFactory.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + + // Dispatch through the handler pipeline (skipAuthorization: the originating server already enforced authorization) + await _options.Registration.HandleAsync(mediator, typedMessage, callContext, linkedToken, null, skipAuthorization: true).ConfigureAwait(false); + + if (_options.AutoComplete) + await _client.CompleteAsync(message, stoppingToken).ConfigureAwait(false); + + if (_workerInfo is not null) + Interlocked.Increment(ref _workerInfo._messagesProcessed); + + if (_stateStore is not null) + await _stateStore.IncrementCounterAsync(_options.QueueName, "processed", 1, stoppingToken).ConfigureAwait(false); + + // Update state to Completed + await UpdateJobStateCompleted(jobId, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Host is shutting down β€” abandon so message becomes visible for retry + _logger.LogDebug("Host stopping, abandoning message {MessageId} on {QueueName}", message.Id, _options.QueueName); + await AbandonAsync(message).ConfigureAwait(false); + } + catch (OperationCanceledException) when (trackProgress && jobId is not null && !stoppingToken.IsCancellationRequested) + { + // Could be user-requested cancellation or per-message timeout + var wasCancellationRequested = await _stateStore!.IsCancellationRequestedAsync(jobId, stoppingToken).ConfigureAwait(false); + if (wasCancellationRequested) + { + _logger.LogInformation("Message {MessageId} on {QueueName} was cancelled (job {JobId})", message.Id, _options.QueueName, jobId); + await UpdateJobStateCancelled(jobId, stoppingToken).ConfigureAwait(false); + if (_options.AutoComplete) + await _client.CompleteAsync(message, stoppingToken).ConfigureAwait(false); + } + else + { + _logger.LogWarning("Message {MessageId} on {QueueName} timed out after {Timeout}", message.Id, _options.QueueName, _options.VisibilityTimeout); + await UpdateJobStateFailed(jobId, $"Timed out after {_options.VisibilityTimeout}", stoppingToken).ConfigureAwait(false); + if (_options.AutoComplete) + await AbandonAsync(message, stoppingToken).ConfigureAwait(false); + } + + if (_workerInfo is not null) + Interlocked.Increment(ref _workerInfo._messagesFailed); + + if (_stateStore is not null) + await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Per-message timeout (no tracking) + _logger.LogWarning("Message {MessageId} on {QueueName} timed out after {Timeout}", message.Id, _options.QueueName, _options.VisibilityTimeout); + if (_options.AutoComplete) + await AbandonAsync(message, stoppingToken).ConfigureAwait(false); + + if (_workerInfo is not null) + Interlocked.Increment(ref _workerInfo._messagesFailed); + + if (_stateStore is not null) + await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing message {MessageId} on {QueueName} (attempt {DequeueCount}/{MaxAttempts})", + message.Id, _options.QueueName, message.DequeueCount, _options.MaxRetries + 1); + + await UpdateJobStateFailed(jobId, ex.Message, stoppingToken).ConfigureAwait(false); + + if (_options.AutoComplete) + await AbandonAsync(message, stoppingToken).ConfigureAwait(false); + + if (_workerInfo is not null) + Interlocked.Increment(ref _workerInfo._messagesFailed); + + if (_stateStore is not null) + await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + } + finally + { + // Stop the cancellation polling task + if (cancellationPollTask is not null) + { + timeoutCts.Cancel(); + try { await cancellationPollTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + } + } + + private async Task PollForCancellationAsync(string jobId, CancellationTokenSource messageTimeoutCts, CancellationToken stoppingToken) + { + try + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(messageTimeoutCts.Token, stoppingToken); + while (!linkedCts.Token.IsCancellationRequested) + { + await Task.Delay(_options.CancellationPollInterval, _timeProvider, linkedCts.Token).ConfigureAwait(false); + + if (await _stateStore!.IsCancellationRequestedAsync(jobId, linkedCts.Token).ConfigureAwait(false)) + { + _logger.LogDebug("Cancellation requested for job {JobId} on {QueueName}", jobId, _options.QueueName); + await messageTimeoutCts.CancelAsync().ConfigureAwait(false); + return; + } + } + } + catch (OperationCanceledException) + { + // Expected when message completes or host stops + } + } + + private async Task UpdateJobProgressAsync(string jobId, int percent, string? message, CancellationToken ct) + { + if (_stateStore is null) return; + + // Check for cancellation on every progress report + if (await _stateStore.IsCancellationRequestedAsync(jobId, ct).ConfigureAwait(false)) + throw new OperationCanceledException("Job cancellation was requested."); + + var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); + if (state is null) return; + + state.Progress = Math.Clamp(percent, 0, 100); + state.ProgressMessage = message; + state.LastUpdatedUtc = _timeProvider.GetUtcNow(); + await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); + } + + private async Task UpdateJobStateCompleted(string? jobId, CancellationToken ct) + { + if (jobId is null || _stateStore is null) return; + + var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); + if (state is null) return; + + var now = _timeProvider.GetUtcNow(); + state.Status = QueueJobStatus.Completed; + state.Progress = 100; + state.CompletedUtc = now; + state.LastUpdatedUtc = now; + await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); + } + + private async Task UpdateJobStateFailed(string? jobId, string errorMessage, CancellationToken ct) + { + if (jobId is null || _stateStore is null) return; + + var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); + if (state is null) return; + + var now = _timeProvider.GetUtcNow(); + state.Status = QueueJobStatus.Failed; + state.ErrorMessage = errorMessage; + state.CompletedUtc = now; + state.LastUpdatedUtc = now; + await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); + } + + private async Task UpdateJobStateCancelled(string? jobId, CancellationToken ct) + { + if (jobId is null || _stateStore is null) return; + + var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); + if (state is null) return; + + var now = _timeProvider.GetUtcNow(); + state.Status = QueueJobStatus.Cancelled; + state.CompletedUtc = now; + state.LastUpdatedUtc = now; + await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); + } + + private async Task AbandonAsync(QueueMessage message, CancellationToken cancellationToken) + { + var delay = ComputeRetryDelay(message.DequeueCount); + if (delay > TimeSpan.Zero) + { + try + { + await _client.AbandonAsync(message, delay, cancellationToken).ConfigureAwait(false); + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to abandon message {MessageId} on {QueueName} with delay {Delay}", + message.Id, _options.QueueName, delay); + } + } + + await AbandonAsync(message).ConfigureAwait(false); + } + + public TimeSpan ComputeRetryDelay(int dequeueCount) + { + if (_options.RetryPolicy == QueueRetryPolicy.None) + return TimeSpan.Zero; + + var baseDelay = _options.RetryDelay; + if (baseDelay <= TimeSpan.Zero) + return TimeSpan.Zero; + + // dequeueCount is 1-based; first retry is after attempt 1 + int retryNumber = Math.Max(0, dequeueCount - 1); + + double delayMs = _options.RetryPolicy switch + { + QueueRetryPolicy.Fixed => baseDelay.TotalMilliseconds, + QueueRetryPolicy.Exponential => baseDelay.TotalMilliseconds * Math.Pow(2, retryNumber), + _ => 0 + }; + + // Apply proportional jitter (Β±10% of the computed delay) + double jitterRange = delayMs * 0.1; + double jitter; + lock (s_jitterRandom) + { + jitter = (s_jitterRandom.NextDouble() * 2 - 1) * jitterRange; + } + delayMs = Math.Max(0, delayMs + jitter); + + // Cap at 15 minutes to prevent unreasonably long delays + return TimeSpan.FromMilliseconds(Math.Min(delayMs, TimeSpan.FromMinutes(15).TotalMilliseconds)); + } + + private async Task AbandonAsync(QueueMessage message) + { + try + { + await _client.AbandonAsync(message).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to abandon message {MessageId} on {QueueName}", message.Id, _options.QueueName); + } + } + + private async Task DeadLetterAsync(QueueMessage message, string reason) + { + try + { + await _client.DeadLetterAsync(message, reason).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dead-letter message {MessageId} on {QueueName}: {Reason}", + message.Id, _options.QueueName, reason); + } + } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs new file mode 100644 index 00000000..79de126d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs @@ -0,0 +1,80 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Describes a registered queue worker and its runtime statistics. +/// Exposed via for dashboard and monitoring. +/// +public sealed class QueueWorkerInfo +{ + /// + /// The name of the queue this worker processes. + /// + public required string QueueName { get; init; } + + /// + /// The full name of the message type. + /// + public required string MessageTypeName { get; init; } + + /// + /// Number of concurrent consumer tasks. + /// + public int Concurrency { get; init; } + + /// + /// Number of messages fetched per receive batch. + /// + public int PrefetchCount { get; init; } + + /// + /// Maximum retry attempts before dead-lettering. + /// + public int MaxRetries { get; init; } + + /// + /// Message visibility timeout. + /// + public TimeSpan VisibilityTimeout { get; init; } + + /// + /// Queue group for selective hosting. + /// + public string? Group { get; init; } + + /// + /// The retry delay strategy. + /// + public QueueRetryPolicy RetryPolicy { get; init; } + + /// + /// Whether job progress tracking is enabled. + /// + public bool TrackProgress { get; init; } + + // --- Runtime stats (updated atomically by QueueWorker) --- + + internal long _messagesProcessed; + internal long _messagesFailed; + internal long _messagesDeadLettered; + internal volatile bool _isRunning; + + /// + /// Total messages processed successfully since startup. + /// + public long MessagesProcessed => Interlocked.Read(ref _messagesProcessed); + + /// + /// Total messages that failed processing since startup. + /// + public long MessagesFailed => Interlocked.Read(ref _messagesFailed); + + /// + /// Total messages dead-lettered since startup. + /// + public long MessagesDeadLettered => Interlocked.Read(ref _messagesDeadLettered); + + /// + /// Whether the worker is currently running. + /// + public bool IsRunning => _isRunning; +} diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerOptions.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerOptions.cs new file mode 100644 index 00000000..92779370 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerOptions.cs @@ -0,0 +1,84 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Configuration for a single queue worker instance. +/// Built from properties during DI registration, +/// with support for programmatic overrides. +/// +public class QueueWorkerOptions +{ + /// + /// The name of the queue to process. + /// + public required string QueueName { get; init; } + + /// + /// The CLR type of the message this worker processes. + /// + public required Type MessageType { get; init; } + + /// + /// The handler registration for dispatching messages. + /// + public required HandlerRegistration Registration { get; init; } + + /// + /// Number of concurrent consumer tasks. Default is 1. + /// + public int Concurrency { get; init; } = 1; + + /// + /// Number of messages to fetch per receive batch. Default is 1. + /// + public int PrefetchCount { get; init; } = 1; + + /// + /// How long a message remains invisible after dequeue before being + /// redelivered. Handlers can extend this via . + /// Default is 5 minutes. + /// + public TimeSpan VisibilityTimeout { get; init; } = TimeSpan.FromMinutes(5); + + /// + /// Maximum number of retry attempts before the message is dead-lettered (transport-dependent). + /// Default is 2. + /// + public int MaxRetries { get; init; } = 2; + + /// + /// Queue group for selective hosting. When set, this worker only starts + /// if the host is configured for the matching group. + /// + public string? Group { get; init; } + + /// + /// When true, the worker automatically completes the message on success + /// and abandons it on exception. Default is true. + /// + public bool AutoComplete { get; init; } = true; + + /// + /// The retry delay strategy for failed messages. Default is . + /// + public QueueRetryPolicy RetryPolicy { get; init; } = QueueRetryPolicy.Exponential; + + /// + /// The base delay between retries. + /// For , this is the constant delay. + /// For , this is the initial delay that doubles on each retry. + /// Default is 5 seconds. + /// + public TimeSpan RetryDelay { get; init; } = TimeSpan.FromSeconds(5); + + /// + /// When true, job progress and state are tracked via . + /// Enables progress reporting, cancellation, and dashboard visibility. + /// + public bool TrackProgress { get; init; } + + /// + /// The interval at which the worker polls the state store for cancellation requests. + /// Only used when is true. Default is 5 seconds. + /// + public TimeSpan CancellationPollInterval { get; init; } = TimeSpan.FromSeconds(5); +} diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerRegistry.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerRegistry.cs new file mode 100644 index 00000000..3199a1e3 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerRegistry.cs @@ -0,0 +1,22 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Default implementation of . +/// Populated during DI registration and updated at runtime by instances. +/// +internal sealed class QueueWorkerRegistry : IQueueWorkerRegistry +{ + private readonly List _workers = []; + private readonly Dictionary _byQueueName = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList GetWorkers() => _workers; + + public QueueWorkerInfo? GetWorker(string queueName) + => _byQueueName.GetValueOrDefault(queueName); + + internal void Register(QueueWorkerInfo info) + { + _workers.Add(info); + _byQueueName[info.QueueName] = info; + } +} diff --git a/src/Foundatio.Mediator.Queues/MediatorConsumer.cs b/src/Foundatio.Mediator.Queues/MediatorConsumer.cs deleted file mode 100644 index 5665fea8..00000000 --- a/src/Foundatio.Mediator.Queues/MediatorConsumer.cs +++ /dev/null @@ -1,47 +0,0 @@ -using SlimMessageBus; - -namespace Foundatio.Mediator.Queues; - -/// -/// Generic SlimMessageBus consumer that bridges bus messages back through the mediator pipeline. -/// Calls the handler's directly with a -/// containing a , so the passes through -/// to next() instead of re-enqueuing, and so that handler methods can inject -/// for progress reporting and timeout renewal. -/// -public class MediatorConsumer : IConsumer where T : class -{ - private readonly IMediator _mediator; - private readonly HandlerRegistration _registration; - private readonly string _queueName; - - public MediatorConsumer(IMediator mediator, HandlerRegistry registry) - { - _mediator = mediator; - - var registrations = registry.GetRegistrationsForMessageType(typeof(T)); - _registration = registrations.Count switch - { - 0 => throw new InvalidOperationException($"No handler registration found for message type {typeof(T).Name}"), - 1 => registrations[0], - _ => throw new InvalidOperationException($"Multiple handler registrations found for message type {typeof(T).Name}. Queue messages must have exactly one handler.") - }; - - var queueAttr = _registration.GetPreferredAttribute()?.Attribute as QueueAttribute; - _queueName = !string.IsNullOrWhiteSpace(queueAttr?.QueueName) - ? queueAttr!.QueueName! - : typeof(T).Name; - } - - public async Task OnHandle(T message, CancellationToken cancellationToken) - { - var queueContext = new QueueContext - { - QueueName = _queueName, - MessageType = typeof(T) - }; - - using var callContext = CallContext.Rent().Set(queueContext); - await _registration.HandleAsync(_mediator, message, callContext, cancellationToken, null).ConfigureAwait(false); - } -} diff --git a/src/Foundatio.Mediator.Queues/QueueAttribute.cs b/src/Foundatio.Mediator.Queues/QueueAttribute.cs deleted file mode 100644 index 4588b3de..00000000 --- a/src/Foundatio.Mediator.Queues/QueueAttribute.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Foundatio.Mediator; - -namespace Foundatio.Mediator.Queues; - -/// -/// Marks a handler class or method for queue-based processing. -/// When applied, invocations via mediator.InvokeAsync() will publish the message -/// to a message bus for asynchronous processing instead of executing the handler inline. -/// -/// -/// -/// The handler is processed by a that receives messages -/// from SlimMessageBus and dispatches them back through the mediator pipeline (including -/// all middleware except re-enqueuing). -/// -/// -/// Queue infrastructure is backed by SlimMessageBus, which supports in-memory, Kafka, -/// RabbitMQ, Azure Service Bus, and many other transports. -/// -/// -/// -/// -/// [Queue(Concurrency = 3)] -/// public class OrderProcessingHandler -/// { -/// public async Task<Result> HandleAsync( -/// ProcessOrder message, -/// CancellationToken ct) -/// { -/// // ... do work ... -/// return Result.Success(); -/// } -/// } -/// -/// -[UseMiddleware(typeof(QueueMiddleware))] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] -public sealed class QueueAttribute : Attribute -{ - /// - /// Override the queue name. Defaults to the message type name. - /// - public string? QueueName { get; set; } - - /// - /// Maximum number of retry attempts before dead-lettering. Default is 2 (Foundatio default). - /// Total attempts = Retries + 1 (initial attempt + retries). - /// - public int Retries { get; set; } = 2; - - /// - /// Work item timeout as a TimeSpan string (e.g., "00:05:00"). - /// If a message is not completed within this duration, it is automatically abandoned. - /// Default is 5 minutes. - /// - public string? Timeout { get; set; } - - /// - /// Number of concurrent workers processing this queue. Default is 1. - /// - public int Concurrency { get; set; } = 1; - - /// - /// When true, the worker automatically completes the message on success - /// and abandons it on exception. Default is true. - /// - public bool AutoComplete { get; set; } = true; -} diff --git a/src/Foundatio.Mediator.Queues/QueueContext.cs b/src/Foundatio.Mediator.Queues/QueueContext.cs deleted file mode 100644 index 5c6c2248..00000000 --- a/src/Foundatio.Mediator.Queues/QueueContext.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Foundatio.Mediator.Queues; - -/// -/// Provides queue-specific context to handler methods during message processing. -/// Injected via so handlers can report progress and -/// renew message timeouts for long-running work. -/// -/// -/// Handlers that accept a parameter will receive it -/// automatically via CallContext injection when processing a queued message: -/// -/// [Queue(Concurrency = 3)] -/// public class LongRunningHandler -/// { -/// public async Task HandleAsync( -/// ProcessLargeFile message, -/// QueueContext queueContext, -/// CancellationToken ct) -/// { -/// foreach (var chunk in GetChunks(message)) -/// { -/// await ProcessChunkAsync(chunk, ct); -/// await queueContext.RenewTimeoutAsync(TimeSpan.FromMinutes(5), ct); -/// await queueContext.ReportProgressAsync(ct); -/// } -/// } -/// } -/// -/// -public class QueueContext -{ - /// - /// The name of the queue this message was received from. - /// - public string QueueName { get; init; } = string.Empty; - - /// - /// The message type being processed. - /// - public Type? MessageType { get; init; } - - /// - /// Delegate invoked by to signal that the handler - /// is still actively working. Set by the consumer infrastructure. - /// - public Func? OnReportProgress { get; init; } - - /// - /// Delegate invoked by to extend the message lock - /// or visibility timeout. Set by the consumer infrastructure. - /// - public Func? OnRenewTimeout { get; init; } - - /// - /// Reports that the handler is still actively processing the message. - /// For transports that support it, this prevents the message from being - /// redelivered or timed out during long-running operations. - /// - /// A cancellation token. - /// A task that completes when the progress report is acknowledged. - public Task ReportProgressAsync(CancellationToken cancellationToken = default) - => OnReportProgress?.Invoke(cancellationToken) ?? Task.CompletedTask; - - /// - /// Extends the message lock or visibility timeout by the specified duration. - /// Use this for long-running handlers to prevent the message from being - /// redelivered to another consumer. - /// - /// The duration to extend the timeout by. - /// A cancellation token. - /// A task that completes when the timeout is renewed. - public Task RenewTimeoutAsync(TimeSpan extension, CancellationToken cancellationToken = default) - => OnRenewTimeout?.Invoke(extension, cancellationToken) ?? Task.CompletedTask; -} diff --git a/src/Foundatio.Mediator.Queues/QueueMiddleware.cs b/src/Foundatio.Mediator.Queues/QueueMiddleware.cs deleted file mode 100644 index 77580c4d..00000000 --- a/src/Foundatio.Mediator.Queues/QueueMiddleware.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using SlimMessageBus; - -namespace Foundatio.Mediator.Queues; - -/// -/// Middleware that intercepts handler invocations for -decorated handlers. -/// -/// -/// -/// On the enqueue path (normal caller), this middleware publishes the message to -/// SlimMessageBus and returns immediately β€” no other middleware runs. -/// -/// -/// On the process path (when calls back through -/// the mediator), the presence of a in -/// signals that this is a processing invocation. The middleware passes through to next() -/// so the full pipeline (logging, validation, auth, etc.) executes before the handler. -/// -/// -/// Order is set low so this middleware runs as the outermost ExecuteAsync wrapper, -/// ensuring fast enqueue with minimal overhead. -/// -/// -[Middleware(Order = -100, ExplicitOnly = true)] -public class QueueMiddleware -{ - private static readonly ConcurrentDictionary s_publishMethods = new(); - - private static readonly MethodInfo s_publishTypedMethod = typeof(QueueMiddleware) - .GetMethod(nameof(PublishTypedAsync), BindingFlags.NonPublic | BindingFlags.Static)!; - - private readonly IMessageBus _bus; - - public QueueMiddleware(IMessageBus bus) => _bus = bus; - - public async ValueTask ExecuteAsync( - object message, - HandlerExecutionDelegate next, - HandlerExecutionInfo handlerInfo, - CallContext? callContext) - { - // Process path: QueueContext in CallContext signals we're processing from the bus - if (callContext?.TryGet(out _) == true) - return await next().ConfigureAwait(false); - - // Enqueue path: publish to the bus and return immediately - var method = s_publishMethods.GetOrAdd(message.GetType(), - type => s_publishTypedMethod.MakeGenericMethod(type)); - - await ((Task)method.Invoke(null, [_bus, message])!).ConfigureAwait(false); - - return Result.Accepted("Message queued"); - } - - private static Task PublishTypedAsync(IMessageBus bus, T message) where T : class - => bus.Publish(message); -} diff --git a/src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs b/src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs deleted file mode 100644 index a9396b85..00000000 --- a/src/Foundatio.Mediator.Queues/QueueServiceExtensions.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Reflection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using SlimMessageBus.Host; -using SlimMessageBus.Host.Memory; - -namespace Foundatio.Mediator.Queues; - -/// -/// Extension methods for registering SlimMessageBus-backed queue support for Foundatio.Mediator. -/// -public static class QueueServiceExtensions -{ - private static readonly MethodInfo s_configureMethod = typeof(QueueServiceExtensions) - .GetMethod(nameof(ConfigureQueueForType), BindingFlags.NonPublic | BindingFlags.Static)!; - - /// - /// Adds queue processing support to Foundatio.Mediator using SlimMessageBus. - /// Handlers decorated with will have their messages - /// enqueued via the message bus for asynchronous processing. - /// - /// The service collection. - /// - /// Optional configuration callback for the . - /// Use this to set a transport provider (e.g. Kafka, RabbitMQ, Azure Service Bus). - /// If not provided, defaults to the in-memory transport. - /// - /// The service collection for chaining. - /// - /// - /// services.AddMediator(); - /// services.AddMediatorQueues(); - /// - /// // Or with a real transport: - /// services.AddMediatorQueues(mbb => mbb.WithProviderServiceBus(cfg => { ... })); - /// - /// - public static IServiceCollection AddMediatorQueues( - this IServiceCollection services, - Action? configureBus = null) - { - // Prevent double registration - if (services.Any(sd => sd.ServiceType == typeof(QueueMiddleware))) - return services; - - var registry = services.GetHandlerRegistry() - ?? throw new InvalidOperationException( - "AddMediatorQueues requires AddMediator to be called first."); - - var queueHandlers = registry.GetHandlersWithAttribute(); - if (queueHandlers.Count == 0) - return services; - - // Register the middleware and generic consumer - services.AddTransient(); - services.AddTransient(typeof(MediatorConsumer<>)); - - services.AddSlimMessageBus(mbb => - { - // Register produce/consume for each [Queue]-decorated handler - foreach (var handler in queueHandlers) - { - var messageType = handler.MessageType; - if (messageType == null) - continue; - - var queueAttr = handler.GetPreferredAttribute()?.Attribute as QueueAttribute; - var queueName = !string.IsNullOrWhiteSpace(queueAttr?.QueueName) - ? queueAttr!.QueueName! - : messageType.Name; - var concurrency = queueAttr?.Concurrency ?? 1; - - s_configureMethod.MakeGenericMethod(messageType) - .Invoke(null, [mbb, queueName, concurrency]); - } - - // Let the caller configure transport, serializer, etc. - if (configureBus != null) - configureBus(mbb); - else - mbb.WithProviderMemory(); // Default to in-memory - }); - - return services; - } - - /// - /// Strongly-typed helper invoked via reflection to register produce/consume - /// for a specific message type with the . - /// - private static void ConfigureQueueForType( - MessageBusBuilder mbb, string queueName, int concurrency) where T : class - { - mbb.Produce(x => x.DefaultTopic(queueName)); - mbb.Consume(x => - { - x.Topic(queueName); - x.WithConsumer>(); - x.Instances(concurrency); - }); - } -} diff --git a/src/Foundatio.Mediator/FoundatioModuleGenerator.cs b/src/Foundatio.Mediator/FoundatioModuleGenerator.cs index 8878e82b..c12d22f5 100644 --- a/src/Foundatio.Mediator/FoundatioModuleGenerator.cs +++ b/src/Foundatio.Mediator/FoundatioModuleGenerator.cs @@ -163,7 +163,7 @@ public static void Execute(SourceProductionContext context, CompilationInfo comp } else { - source.AppendLine($" (mediator, message, callContext, cancellationToken, responseType) => new ValueTask({handlerClassName}.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)),"); + source.AppendLine($" (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask({handlerClassName}.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)),"); source.AppendLine($" {handlerClassName}.UntypedHandle,"); } @@ -237,7 +237,10 @@ public HttpContextAuthorizationContextProvider(Microsoft.AspNetCore.Http.IHttpCo public System.Security.Claims.ClaimsPrincipal? GetCurrentPrincipal() { - return _httpContextAccessor.HttpContext?.User; + // Prefer HttpContext.User for HTTP requests; fall back to Thread.CurrentPrincipal + // for background workers (e.g., queue workers that reconstruct the principal from headers) + return _httpContextAccessor.HttpContext?.User + ?? System.Threading.Thread.CurrentPrincipal as System.Security.Claims.ClaimsPrincipal; } } """); diff --git a/src/Foundatio.Mediator/HandlerGenerator.cs b/src/Foundatio.Mediator/HandlerGenerator.cs index 155b4a11..a429eef4 100644 --- a/src/Foundatio.Mediator/HandlerGenerator.cs +++ b/src/Foundatio.Mediator/HandlerGenerator.cs @@ -138,7 +138,7 @@ private static void GenerateHandleMethod(IndentedStringBuilder source, HandlerIn string asyncModifier = (isAsyncMethod && !canSkipAsyncStateMachine) ? "async " : ""; - source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken)") + source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false)") .AppendLine("{"); source.IncrementIndent(); @@ -301,7 +301,7 @@ private static void GenerateHandleItemMethods(IndentedStringBuilder source, Hand string methodReturnType = GetMethodSignatureReturnType(isAsyncMethod, isVoid: false, returnTypeName); string asyncModifier = isAsyncMethod ? "async " : ""; - source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken)"); + source.AppendLine($"public static {asyncModifier}{methodReturnType} {methodName}(Foundatio.Mediator.IMediator mediator, {handler.MessageType.FullName} message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false)"); source.AppendLine("{"); source.IncrementIndent(); @@ -561,9 +561,16 @@ private static void EmitOpenTelemetrySetup(IndentedStringBuilder source, Handler if (!configuration.OpenTelemetryEnabled) return; - source.AppendLine($"using var activity = MediatorActivitySource.Instance.StartActivity(\"{handler.MessageType.Identifier}\");"); + // For notification handlers (void return), include the handler class name to + // distinguish multiple handlers for the same message type in traces. + var activityName = handler.ReturnType.IsVoid + ? $"Handle {handler.Identifier}.{handler.MessageType.Identifier}" + : $"Handle {handler.MessageType.Identifier}"; + + source.AppendLine($"using var activity = MediatorActivitySource.Instance.StartActivity(\"{activityName}\");"); source.AppendLine($"activity?.SetTag(\"messaging.system\", \"Foundatio.Mediator\");"); source.AppendLine($"activity?.SetTag(\"messaging.message.type\", \"{handler.MessageType.FullName}\");"); + source.AppendLine($"activity?.SetTag(\"messaging.handler\", \"{handler.Identifier}\");"); variables["System.Diagnostics.Activity"] = "activity"; } @@ -588,7 +595,10 @@ private static void EmitAuthorizationCheck(IndentedStringBuilder source, Handler return; source.AppendLine(); - source.AppendLine("// Authorization check"); + source.AppendLine("// Authorization check (skipped for publish/event dispatch)"); + source.AppendLine("if (!skipAuthorization)"); + source.AppendLine("{"); + source.IncrementIndent(); source.AppendLine("var authContextProvider = serviceProvider.GetRequiredService();"); source.AppendLine("var authService = serviceProvider.GetRequiredService();"); source.AppendLine("var principal = authContextProvider.GetCurrentPrincipal();"); @@ -611,6 +621,8 @@ private static void EmitAuthorizationCheck(IndentedStringBuilder source, Handler source.AppendLine("throw new System.UnauthorizedAccessException(authResult.FailureReason ?? \"Authorization failed.\");"); } + source.DecrementIndent(); + source.AppendLine("}"); source.DecrementIndent(); source.AppendLine("}"); source.AppendLine(); @@ -1004,8 +1016,8 @@ private static void GenerateUntypedHandleMethod(IndentedStringBuilder source, Ha bool isAsyncMethod = handler.IsAsync || handler.ReturnType.IsTuple; source.AppendLine(isAsyncMethod - ? "public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType)" - : "public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType)"); + ? "public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false)" + : "public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false)"); source.AppendLine("{"); source.IncrementIndent(); diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj b/tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj new file mode 100644 index 00000000..becb0abf --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj @@ -0,0 +1,30 @@ + + + + + + net10.0 + enable + true + false + $(NoWarn);NU1510 + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/GlobalUsings.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/LocalStackFixture.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/LocalStackFixture.cs new file mode 100644 index 00000000..84ae676e --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/LocalStackFixture.cs @@ -0,0 +1,40 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; + +namespace Foundatio.Mediator.Distributed.Aws.Tests; + +/// +/// Aspire fixture that manages a LocalStack container for AWS integration tests. +/// The container is started once and shared across all tests in the collection. +/// +public class LocalStackFixture : IAsyncLifetime +{ + public string ServiceUrl { get; private set; } = null!; + public DistributedApplication App { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + var builder = DistributedApplicationTestingBuilder.Create(); + + builder.AddContainer("localstack", "localstack/localstack", "latest") + .WithHttpEndpoint(targetPort: 4566, name: "main") + .WithHttpHealthCheck("/_localstack/health", endpointName: "main") + .WithEnvironment("SERVICES", "sqs,sns"); + + App = await builder.BuildAsync(); + await App.StartAsync(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await App.ResourceNotifications.WaitForResourceHealthyAsync( + "localstack", cts.Token); + + ServiceUrl = App.GetEndpoint("localstack", "main").ToString().TrimEnd('/'); + } + + public async ValueTask DisposeAsync() + { + if (App is not null) + await App.DisposeAsync(); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs new file mode 100644 index 00000000..b7c05810 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs @@ -0,0 +1,196 @@ +#pragma warning disable xUnit1051 +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Amazon.SQS; +using Foundatio.Mediator.Distributed; +using Foundatio.Mediator.Distributed.Aws; +using Foundatio.Xunit; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed.Aws.Tests; + +/// +/// SNS+SQS pub/sub client tests running against LocalStack managed by Aspire. +/// +public class SnsSqsPubSubClientTests(LocalStackFixture fixture, ITestOutputHelper output) + : TestWithLoggingBase(output), IClassFixture +{ + private SnsSqsPubSubClient CreateClient(string? hostId = null) + { + var credentials = new BasicAWSCredentials("test", "test"); + + var snsClient = new AmazonSimpleNotificationServiceClient( + credentials, + new AmazonSimpleNotificationServiceConfig { ServiceURL = fixture.ServiceUrl }); + + var sqsClient = new AmazonSQSClient( + credentials, + new AmazonSQSConfig { ServiceURL = fixture.ServiceUrl }); + + var options = new SnsSqsPubSubClientOptions + { + AutoCreate = true, + WaitTimeSeconds = 1, + CleanupOnDispose = true + }; + + var notificationOptions = new DistributedNotificationOptions + { + HostId = hostId ?? Guid.NewGuid().ToString("N"), + Topic = $"test-topic-{Guid.NewGuid():N}" + }; + + return new SnsSqsPubSubClient( + snsClient, + sqsClient, + options, + notificationOptions, + Log.CreateLogger()); + } + + [Fact] + public async Task PublishAsync_WithNoSubscribers_DoesNotThrow() + { + await using var client = CreateClient(); + + await client.PublishAsync("no-sub-topic", "hello"u8.ToArray(), cancellationToken: TestCancellationToken); + } + + [Fact] + public async Task SubscribeAsync_ReceivesPublishedMessage() + { + await using var client = CreateClient(); + var topic = $"test-{Guid.NewGuid():N}"; + + PubSubMessage? received = null; + var signal = new SemaphoreSlim(0); + + await using var sub = await client.SubscribeAsync(topic, (msg, ct) => + { + received = msg; + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + var headers = new Dictionary { ["key"] = "value" }; + await client.PublishAsync(topic, "hello"u8.ToArray(), headers, TestCancellationToken); + + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30)), + "Timed out waiting for message"); + Assert.NotNull(received); + Assert.Equal("hello"u8.ToArray(), received.Body.ToArray()); + Assert.Equal("value", received.Headers["key"]); + } + + [Fact] + public async Task SubscribeAsync_MultipleMessages_AllReceived() + { + await using var client = CreateClient(); + var topic = $"test-{Guid.NewGuid():N}"; + + var received = new List(); + var signal = new SemaphoreSlim(0); + + await using var sub = await client.SubscribeAsync(topic, (msg, ct) => + { + lock (received) + received.Add(System.Text.Encoding.UTF8.GetString(msg.Body.Span)); + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + for (int i = 0; i < 3; i++) + await client.PublishAsync(topic, System.Text.Encoding.UTF8.GetBytes($"msg-{i}"), cancellationToken: TestCancellationToken); + + for (int i = 0; i < 3; i++) + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30)), $"Timed out waiting for message {i}"); + + Assert.Equal(3, received.Count); + for (int i = 0; i < 3; i++) + Assert.Contains($"msg-{i}", received); + } + + [Fact] + public async Task SubscribeAsync_HeadersRoundTrip() + { + await using var client = CreateClient(); + var topic = $"test-{Guid.NewGuid():N}"; + + PubSubMessage? received = null; + var signal = new SemaphoreSlim(0); + + await using var sub = await client.SubscribeAsync(topic, (msg, ct) => + { + received = msg; + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + var headers = new Dictionary + { + ["h1"] = "v1", + ["h2"] = "v2", + ["h3"] = "v3" + }; + await client.PublishAsync(topic, "test"u8.ToArray(), headers, TestCancellationToken); + + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); + Assert.NotNull(received); + Assert.Equal("v1", received.Headers["h1"]); + Assert.Equal("v2", received.Headers["h2"]); + Assert.Equal("v3", received.Headers["h3"]); + } + + [Fact] + public async Task DisposeSubscription_StopsReceiving() + { + await using var client = CreateClient(); + var topic = $"test-{Guid.NewGuid():N}"; + + int count = 0; + var signal = new SemaphoreSlim(0); + + var sub = await client.SubscribeAsync(topic, (msg, ct) => + { + Interlocked.Increment(ref count); + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await client.PublishAsync(topic, "msg1"u8.ToArray(), cancellationToken: TestCancellationToken); + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); + Assert.Equal(1, count); + + // Dispose subscription + await sub.DisposeAsync(); + + await client.PublishAsync(topic, "msg2"u8.ToArray(), cancellationToken: TestCancellationToken); + await Task.Delay(TimeSpan.FromSeconds(3), TestCancellationToken); + + Assert.Equal(1, count); // Should not have received msg2 + } + + [Fact] + public async Task PublishAsync_NoHeaders_ReceivesEmptyHeaders() + { + await using var client = CreateClient(); + var topic = $"test-{Guid.NewGuid():N}"; + + PubSubMessage? received = null; + var signal = new SemaphoreSlim(0); + + await using var sub = await client.SubscribeAsync(topic, (msg, ct) => + { + received = msg; + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await client.PublishAsync(topic, "no-headers"u8.ToArray(), cancellationToken: TestCancellationToken); + + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); + Assert.NotNull(received); + Assert.Equal("no-headers"u8.ToArray(), received.Body.ToArray()); + Assert.Empty(received.Headers); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs new file mode 100644 index 00000000..0e9d57cf --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs @@ -0,0 +1,251 @@ +#pragma warning disable xUnit1051 +using Amazon.Runtime; +using Amazon.SQS; +using Foundatio.Mediator.Distributed; +using Foundatio.Mediator.Distributed.Aws; +using Foundatio.Mediator.Distributed.Tests; +using Foundatio.Xunit; + +namespace Foundatio.Mediator.Distributed.Aws.Tests; + +/// +/// SQS queue client tests running against LocalStack managed by Aspire. +/// The LocalStack container is automatically started and stopped. +/// +public class SqsQueueClientTests(LocalStackFixture fixture, ITestOutputHelper output) + : QueueClientTestBase(output), IClassFixture +{ + protected override string TestQueueName => $"test-{Guid.NewGuid():N}"; + + protected override IQueueClient CreateClient() + { + var sqsClient = new AmazonSQSClient( + new BasicAWSCredentials("test", "test"), + new AmazonSQSConfig { ServiceURL = fixture.ServiceUrl }); + + return new SqsQueueClient(sqsClient, new SqsQueueClientOptions + { + AutoCreateQueues = true, + WaitTimeSeconds = 1 // Short poll for faster tests + }); + } + + // ── SQS-specific tests ───────────────────────────────────────────────── + + [Fact] + public async Task SendAsync_LargeHeaders_RoundTrip() + { + var client = CreateClient(); + var queueName = TestQueueName; + + // SQS supports up to 10 message attributes + var headers = new Dictionary(); + for (int i = 0; i < 10; i++) + headers[$"header-{i}"] = $"value-{i}-{new string('x', 100)}"; + + await client.SendAsync(queueName, new QueueEntry + { + Body = "test"u8.ToArray(), + Headers = headers + }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + Assert.Single(messages); + + foreach (var (key, value) in headers) + { + Assert.True(messages[0].Headers.ContainsKey(key), $"Missing header: {key}"); + Assert.Equal(value, messages[0].Headers[key]); + } + } + + [Fact] + public async Task AbandonAsync_MakesMessageImmediatelyVisible() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "abandon-test"u8.ToArray() }, TestCancellationToken); + + var first = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(first); + + await client.AbandonAsync(first[0], cancellationToken: TestCancellationToken); + + // Message should be immediately visible again (visibility set to 0) + var second = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(second); + Assert.Equal("abandon-test"u8.ToArray(), second[0].Body.ToArray()); + Assert.True(second[0].DequeueCount >= 2); + } + + [Fact] + public async Task CompleteAsync_DeletesMessage_FromSqs() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "delete-test"u8.ToArray() }, TestCancellationToken); + + var msgs = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(msgs); + + await client.CompleteAsync(msgs[0], TestCancellationToken); + + // No more messages + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var remaining = await client.ReceiveAsync(queueName, 10, cts.Token); + Assert.Empty(remaining); + } + + [Fact] + public async Task SendBatchAsync_SendsUpToTenMessages() + { + var client = CreateClient(); + var queueName = TestQueueName; + + // Send exactly 10 (SQS batch limit) + var entries = Enumerable.Range(0, 10).Select(i => new QueueEntry + { + Body = System.Text.Encoding.UTF8.GetBytes($"batch-{i}"), + Headers = new Dictionary { ["index"] = i.ToString() } + }).ToList(); + + await client.SendBatchAsync(queueName, entries, TestCancellationToken); + + var received = new List(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + while (received.Count < 10 && !cts.IsCancellationRequested) + { + var batch = await client.ReceiveAsync(queueName, 10, cts.Token); + received.AddRange(batch); + } + + Assert.Equal(10, received.Count); + } + + [Fact] + public async Task SendBatchAsync_MoreThanTen_SplitsIntoBatches() + { + var client = CreateClient(); + var queueName = TestQueueName; + + // Send 15 messages β€” should be split into batches of 10 + 5 + var entries = Enumerable.Range(0, 15).Select(i => new QueueEntry + { + Body = System.Text.Encoding.UTF8.GetBytes($"big-batch-{i}") + }).ToList(); + + await client.SendBatchAsync(queueName, entries, TestCancellationToken); + + var received = new List(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + while (received.Count < 15 && !cts.IsCancellationRequested) + { + var batch = await client.ReceiveAsync(queueName, 10, cts.Token); + received.AddRange(batch); + } + + Assert.Equal(15, received.Count); + } + + [Fact] + public async Task ReceivedMessage_HasSqsMetadata() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "metadata-test"u8.ToArray() }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(messages); + + var msg = messages[0]; + Assert.False(string.IsNullOrEmpty(msg.Id)); + Assert.Equal(queueName, msg.QueueName); + Assert.Equal(1, msg.DequeueCount); + Assert.True(msg.EnqueuedAt > DateTimeOffset.MinValue); + Assert.True(msg.DequeuedAt > DateTimeOffset.MinValue); + + // NativeMessage should be the SQS Message + Assert.NotNull(msg.NativeMessage); + Assert.IsType(msg.NativeMessage); + } + + [Fact] + public async Task RenewTimeoutAsync_ExtendsVisibility() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "renew-test"u8.ToArray() }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(messages); + + // Extend visibility β€” should not throw + await client.RenewTimeoutAsync(messages[0], TimeSpan.FromMinutes(1), TestCancellationToken); + + // Complete so we don't leave the message in the queue + await client.CompleteAsync(messages[0], TestCancellationToken); + } + + // ── Dead-letter ──────────────────────────────────────────────────── + + [Fact] + public async Task DeadLetterAsync_SendsMessageToDLQAndCompletesOriginal() + { + var client = CreateClient(); + var queueName = TestQueueName; + var dlqName = $"{queueName}-dead-letter"; + + await client.SendAsync(queueName, new QueueEntry + { + Body = "poison"u8.ToArray(), + Headers = new Dictionary { ["custom"] = "value" } + }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(messages); + + await client.DeadLetterAsync(messages[0], "Bad format", TestCancellationToken); + + // Original queue should be empty + using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var remaining = await client.ReceiveAsync(queueName, 10, cts1.Token); + Assert.Empty(remaining); + + // DLQ should have the message with dead-letter headers + var dlqMessages = await client.ReceiveAsync(dlqName, 10, TestCancellationToken); + Assert.Single(dlqMessages); + Assert.Equal("poison"u8.ToArray(), dlqMessages[0].Body.ToArray()); + + // Verify dead-letter metadata headers + Assert.Equal("Bad format", dlqMessages[0].Headers[MessageHeaders.DeadLetterReason]); + Assert.True(dlqMessages[0].Headers.ContainsKey(MessageHeaders.DeadLetteredAt)); + Assert.Equal(queueName, dlqMessages[0].Headers[MessageHeaders.OriginalQueueName]); + + // Preserve original headers + Assert.Equal("value", dlqMessages[0].Headers["custom"]); + } + + [Fact] + public async Task AbandonAsync_WithDelay_MakesMessageVisibleAfterDelay() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "delay-test"u8.ToArray() }, TestCancellationToken); + + var first = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(first); + + // Abandon with 2 second delay + await client.AbandonAsync(first[0], TimeSpan.FromSeconds(2), TestCancellationToken); + + // Should NOT be visible immediately + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var immediate = await client.ReceiveAsync(queueName, 1, cts.Token); + Assert.Empty(immediate); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs new file mode 100644 index 00000000..a2377925 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs @@ -0,0 +1,119 @@ +using Foundatio.Mediator.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Foundatio.Mediator.Distributed.Tests; + +public class ComputeRetryDelayTests +{ + private static QueueWorker CreateWorker(QueueRetryPolicy policy, TimeSpan baseDelay) + { + var options = new QueueWorkerOptions + { + QueueName = "test", + MessageType = typeof(object), + Registration = null!, + RetryPolicy = policy, + RetryDelay = baseDelay + }; + + return new QueueWorker( + new InMemoryQueueClient(), + new ServiceCollection().BuildServiceProvider().GetRequiredService(), + options, + null, + NullLogger.Instance); + } + + [Fact] + public void None_ReturnsZero() + { + var worker = CreateWorker(QueueRetryPolicy.None, TimeSpan.FromSeconds(5)); + Assert.Equal(TimeSpan.Zero, worker.ComputeRetryDelay(1)); + Assert.Equal(TimeSpan.Zero, worker.ComputeRetryDelay(5)); + } + + [Fact] + public void ZeroBaseDelay_ReturnsZero() + { + var worker = CreateWorker(QueueRetryPolicy.Exponential, TimeSpan.Zero); + Assert.Equal(TimeSpan.Zero, worker.ComputeRetryDelay(3)); + } + + [Fact] + public void Fixed_ReturnsSameDelayWithinJitterBounds() + { + var baseDelay = TimeSpan.FromSeconds(10); + var worker = CreateWorker(QueueRetryPolicy.Fixed, baseDelay); + + for (int attempt = 1; attempt <= 5; attempt++) + { + var delay = worker.ComputeRetryDelay(attempt); + // Fixed: always ~baseDelay Β±10% jitter + Assert.InRange(delay.TotalMilliseconds, + baseDelay.TotalMilliseconds * 0.9, + baseDelay.TotalMilliseconds * 1.1); + } + } + + [Fact] + public void Exponential_DoublesEachRetry() + { + var baseDelay = TimeSpan.FromSeconds(5); + var worker = CreateWorker(QueueRetryPolicy.Exponential, baseDelay); + + // dequeueCount=1 β†’ retryNumber=0 β†’ 5s * 2^0 = 5s + var delay1 = worker.ComputeRetryDelay(1); + Assert.InRange(delay1.TotalSeconds, 4.5, 5.5); + + // dequeueCount=2 β†’ retryNumber=1 β†’ 5s * 2^1 = 10s + var delay2 = worker.ComputeRetryDelay(2); + Assert.InRange(delay2.TotalSeconds, 9.0, 11.0); + + // dequeueCount=3 β†’ retryNumber=2 β†’ 5s * 2^2 = 20s + var delay3 = worker.ComputeRetryDelay(3); + Assert.InRange(delay3.TotalSeconds, 18.0, 22.0); + + // dequeueCount=4 β†’ retryNumber=3 β†’ 5s * 2^3 = 40s + var delay4 = worker.ComputeRetryDelay(4); + Assert.InRange(delay4.TotalSeconds, 36.0, 44.0); + } + + [Fact] + public void Exponential_CapsAt15Minutes() + { + var baseDelay = TimeSpan.FromSeconds(5); + var worker = CreateWorker(QueueRetryPolicy.Exponential, baseDelay); + + // dequeueCount=20 β†’ retryNumber=19 β†’ 5s * 2^19 = 2,621,440s (way over 15min) + var delay = worker.ComputeRetryDelay(20); + Assert.True(delay <= TimeSpan.FromMinutes(15), + $"Expected <= 15 minutes but got {delay}"); + // Should be at the cap (within jitter) + Assert.InRange(delay.TotalMinutes, 13.5, 15.0); + } + + [Fact] + public void JitterIsProportional() + { + var baseDelay = TimeSpan.FromSeconds(10); + var worker = CreateWorker(QueueRetryPolicy.Fixed, baseDelay); + + // Run many iterations to verify jitter stays within Β±10% + var delays = Enumerable.Range(0, 100) + .Select(_ => worker.ComputeRetryDelay(1).TotalMilliseconds) + .ToList(); + + var min = delays.Min(); + var max = delays.Max(); + + Assert.True(min >= baseDelay.TotalMilliseconds * 0.9, + $"Min delay {min}ms is below 90% of base ({baseDelay.TotalMilliseconds * 0.9}ms)"); + Assert.True(max <= baseDelay.TotalMilliseconds * 1.1, + $"Max delay {max}ms is above 110% of base ({baseDelay.TotalMilliseconds * 1.1}ms)"); + + // Verify there IS some variance (not all identical) + Assert.True(max - min > 1, "Expected jitter to produce some variance"); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs new file mode 100644 index 00000000..4701ce89 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs @@ -0,0 +1,368 @@ +#pragma warning disable xUnit1051 +using Foundatio.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Foundatio.Mediator.Distributed.Tests; + +// ── Distributed notification messages ───────────────────────────────── +public record TestDistributedEvent(string Value) : IDistributedNotification; +public record AnotherDistributedEvent(int Number) : IDistributedNotification; +public record NonDistributedEvent(string Value) : INotification; + +// ── Handlers ────────────────────────────────────────────────────────── +public class TestDistributedEventHandler(HandlerSignal signal) +{ + public void Handle(TestDistributedEvent message) => signal.Record(message.Value); +} + +public class AnotherDistributedEventHandler(HandlerSignal signal) +{ + public void Handle(AnotherDistributedEvent message) => signal.Record(message.Number.ToString()); +} + +public class NonDistributedEventHandler(HandlerSignal signal) +{ + public void Handle(NonDistributedEvent message) => signal.Record(message.Value); +} + +// ── Tests ───────────────────────────────────────────────────────────── +public class DistributedNotificationIntegrationTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + /// + /// Single-node: publishing a distributed notification fires local handlers + /// and the worker publishes to the bus. Since there's only one node with + /// the same HostId, the inbound side skips the message. + /// + [Fact] + public async Task PublishAsync_SingleNode_LocalHandlerFires() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributedNotifications(); + + await using var provider = services.BuildServiceProvider(); + var hostedServices = provider.GetServices().ToList(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + await mediator.PublishAsync(new TestDistributedEvent("hello"), cts.Token); + + // Local handler should fire + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signal.Values); + Assert.Equal("hello", signal.Values[0]); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + /// + /// Two-node simulation: Node A publishes, Node B receives from bus and + /// re-publishes locally β†’ Node B's handler fires. + /// + [Fact] + public async Task PublishAsync_TwoNodes_RemoteHandlerFires() + { + // Shared bus simulating a network transport + var sharedBus = new InMemoryPubSubClient(); + + var signalA = new HandlerSignal(); + var signalB = new HandlerSignal(); + + // ── Node A ── + var servicesA = new ServiceCollection(); + servicesA.AddLogging(); + servicesA.AddSingleton(signalA); + servicesA.AddSingleton(sharedBus); + servicesA.AddMediator(b => b.AddAssembly()); + servicesA.AddMediatorDistributedNotifications(opts => opts.HostId = "node-a"); + + // ── Node B ── + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()); + servicesB.AddMediatorDistributedNotifications(opts => opts.HostId = "node-b"); + + await using var providerA = servicesA.BuildServiceProvider(); + await using var providerB = servicesB.BuildServiceProvider(); + + var hostedA = providerA.GetServices().ToList(); + var hostedB = providerB.GetServices().ToList(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StartAsync(cts.Token); + + try + { + // Give workers a moment to set up subscriptions + await Task.Delay(200, cts.Token); + + // Node A publishes + var mediatorA = providerA.GetRequiredService(); + await mediatorA.PublishAsync(new TestDistributedEvent("from-A"), cts.Token); + + // Node A's local handler fires + await signalA.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalA.Values); + Assert.Equal("from-A", signalA.Values[0]); + + // Node B should receive from bus and fire its local handler + await signalB.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalB.Values); + Assert.Equal("from-A", signalB.Values[0]); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// Verifies that a node does NOT re-broadcast a message it received from the bus β€” + /// the reference set check prevents the outbound loop from sending it back. + /// + [Fact] + public async Task PublishAsync_TwoNodes_NoBroadcastLoop() + { + var sharedBus = new InMemoryPubSubClient(); + + int busPublishCount = 0; + var countingBus = new CountingPubSubClient(sharedBus, () => Interlocked.Increment(ref busPublishCount)); + + var signalA = new HandlerSignal(); + var signalB = new HandlerSignal(); + + // ── Node A (uses counting bus to track publishes) ── + var servicesA = new ServiceCollection(); + servicesA.AddLogging(); + servicesA.AddSingleton(signalA); + servicesA.AddSingleton(countingBus); + servicesA.AddMediator(b => b.AddAssembly()); + servicesA.AddMediatorDistributedNotifications(opts => opts.HostId = "node-a"); + + // ── Node B (also uses counting bus) ── + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(countingBus); + servicesB.AddMediator(b => b.AddAssembly()); + servicesB.AddMediatorDistributedNotifications(opts => opts.HostId = "node-b"); + + await using var providerA = servicesA.BuildServiceProvider(); + await using var providerB = servicesB.BuildServiceProvider(); + + var hostedA = providerA.GetServices().ToList(); + var hostedB = providerB.GetServices().ToList(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StartAsync(cts.Token); + + try + { + await Task.Delay(200, cts.Token); + + var mediatorA = providerA.GetRequiredService(); + await mediatorA.PublishAsync(new TestDistributedEvent("once"), cts.Token); + + // Wait for both handlers to fire + await signalA.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + await signalB.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + + // Wait a bit more to allow any potential re-broadcast to happen + await Task.Delay(500, cts.Token); + + // There should be exactly 1 bus publish (from Node A's outbound) + // Node B should NOT re-publish because the reference set prevents it + Assert.Equal(1, busPublishCount); + + // Each handler should have been called exactly once + Assert.Single(signalA.Values); + Assert.Single(signalB.Values); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// Non-distributed notifications should NOT be published to the bus. + /// + [Fact] + public async Task PublishAsync_NonDistributed_NotSentToBus() + { + int busPublishCount = 0; + var sharedBus = new InMemoryPubSubClient(); + var countingBus = new CountingPubSubClient(sharedBus, () => Interlocked.Increment(ref busPublishCount)); + + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddSingleton(countingBus); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributedNotifications(); + + await using var provider = services.BuildServiceProvider(); + var hostedServices = provider.GetServices().ToList(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + await mediator.PublishAsync(new NonDistributedEvent("local-only"), cts.Token); + + // Local handler fires + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signal.Values); + + // Give time for any bus activity + await Task.Delay(500, cts.Token); + + // Bus should have zero publishes β€” NonDistributedEvent doesn't implement IDistributedNotification + Assert.Equal(0, busPublishCount); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// Self-delivery prevention: messages with matching HostId are skipped + /// by the inbound loop. This test uses a single node β€” the bus message + /// published by outbound arrives back at the same node and should be ignored. + /// + [Fact] + public async Task InboundLoop_SkipsSelfDelivery() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributedNotifications(opts => opts.HostId = "self"); + + await using var provider = services.BuildServiceProvider(); + var hostedServices = provider.GetServices().ToList(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + await mediator.PublishAsync(new TestDistributedEvent("self-test"), cts.Token); + + // Local handler fires once (from the initial local publish) + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + + // Wait to ensure no double-fire from bus loopback + await Task.Delay(500, cts.Token); + + Assert.Single(signal.Values); + Assert.Equal("self-test", signal.Values[0]); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + /// + /// Multiple different notification types all fan out correctly. + /// + [Fact] + public async Task PublishAsync_MultipleDifferentTypes_AllFanOut() + { + var sharedBus = new InMemoryPubSubClient(); + + var signalA = new HandlerSignal(); + var signalB = new HandlerSignal(); + + var servicesA = new ServiceCollection(); + servicesA.AddLogging(); + servicesA.AddSingleton(signalA); + servicesA.AddSingleton(sharedBus); + servicesA.AddMediator(b => b.AddAssembly()); + servicesA.AddMediatorDistributedNotifications(opts => opts.HostId = "node-a"); + + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()); + servicesB.AddMediatorDistributedNotifications(opts => opts.HostId = "node-b"); + + await using var providerA = servicesA.BuildServiceProvider(); + await using var providerB = servicesB.BuildServiceProvider(); + + var hostedA = providerA.GetServices().ToList(); + var hostedB = providerB.GetServices().ToList(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StartAsync(cts.Token); + + try + { + await Task.Delay(200, cts.Token); + + var mediatorA = providerA.GetRequiredService(); + await mediatorA.PublishAsync(new TestDistributedEvent("event1"), cts.Token); + await mediatorA.PublishAsync(new AnotherDistributedEvent(42), cts.Token); + + // Node A fires both handler types locally + await signalA.WaitAsync(count: 2, timeout: TimeSpan.FromSeconds(5)); + + // Node B receives both from bus + await signalB.WaitAsync(count: 2, timeout: TimeSpan.FromSeconds(5)); + + Assert.Contains("event1", signalB.Values); + Assert.Contains("42", signalB.Values); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } +} + +// ── Test helper: counting bus decorator ────────────────────────────── +internal sealed class CountingPubSubClient(IPubSubClient inner, Action onPublish) : IPubSubClient +{ + public async Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default) + { + onPublish(); + await inner.PublishAsync(topic, body, headers, cancellationToken); + } + + public Task SubscribeAsync(string topic, Func handler, CancellationToken cancellationToken = default) + => inner.SubscribeAsync(topic, handler, cancellationToken); +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj b/tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj new file mode 100644 index 00000000..3d73f9ce --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj @@ -0,0 +1,43 @@ + + + + net10.0 + enable + true + false + $(NoWarn);NU1510 + + Generated + true + + $(InterceptorsNamespaces);Foundatio.Mediator + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Foundatio.Mediator.Distributed.Tests/GlobalUsings.cs b/tests/Foundatio.Mediator.Distributed.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs new file mode 100644 index 00000000..20d4dcbf --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs @@ -0,0 +1,156 @@ +#pragma warning disable xUnit1051 +using Foundatio.Xunit; + +namespace Foundatio.Mediator.Distributed.Tests; + +public class InMemoryPubSubClientTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public async Task PublishAsync_WithNoSubscribers_DoesNotThrow() + { + using var bus = new InMemoryPubSubClient(); + + await bus.PublishAsync("test-topic", "hello"u8.ToArray(), cancellationToken: TestCancellationToken); + } + + [Fact] + public async Task SubscribeAsync_ReceivesPublishedMessage() + { + using var bus = new InMemoryPubSubClient(); + + PubSubMessage? received = null; + var signal = new SemaphoreSlim(0); + + await using var sub = await bus.SubscribeAsync("test-topic", (msg, ct) => + { + received = msg; + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + var headers = new Dictionary { ["key"] = "value" }; + await bus.PublishAsync("test-topic", "hello"u8.ToArray(), headers, TestCancellationToken); + + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.NotNull(received); + Assert.Equal("hello"u8.ToArray(), received.Body.ToArray()); + Assert.Equal("value", received.Headers["key"]); + } + + [Fact] + public async Task SubscribeAsync_MultipleSubscribers_AllReceive() + { + using var bus = new InMemoryPubSubClient(); + + int count1 = 0, count2 = 0; + var signal = new SemaphoreSlim(0); + + await using var sub1 = await bus.SubscribeAsync("topic", (msg, ct) => + { + Interlocked.Increment(ref count1); + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await using var sub2 = await bus.SubscribeAsync("topic", (msg, ct) => + { + Interlocked.Increment(ref count2); + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await bus.PublishAsync("topic", "data"u8.ToArray(), cancellationToken: TestCancellationToken); + + // Wait for both subscribers + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Equal(1, count1); + Assert.Equal(1, count2); + } + + [Fact] + public async Task SubscribeAsync_DifferentTopics_OnlyMatchingReceives() + { + using var bus = new InMemoryPubSubClient(); + + int topicACount = 0, topicBCount = 0; + var signal = new SemaphoreSlim(0); + + await using var subA = await bus.SubscribeAsync("topic-a", (msg, ct) => + { + Interlocked.Increment(ref topicACount); + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await using var subB = await bus.SubscribeAsync("topic-b", (msg, ct) => + { + Interlocked.Increment(ref topicBCount); + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await bus.PublishAsync("topic-a", "only-a"u8.ToArray(), cancellationToken: TestCancellationToken); + + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); + // Give a moment to ensure topic-b doesn't fire + await Task.Delay(200, TestCancellationToken); + + Assert.Equal(1, topicACount); + Assert.Equal(0, topicBCount); + } + + [Fact] + public async Task DisposeSubscription_StopsReceiving() + { + using var bus = new InMemoryPubSubClient(); + + int count = 0; + var signal = new SemaphoreSlim(0); + + var sub = await bus.SubscribeAsync("topic", (msg, ct) => + { + Interlocked.Increment(ref count); + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await bus.PublishAsync("topic", "msg1"u8.ToArray(), cancellationToken: TestCancellationToken); + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Equal(1, count); + + // Dispose subscription + await sub.DisposeAsync(); + + await bus.PublishAsync("topic", "msg2"u8.ToArray(), cancellationToken: TestCancellationToken); + await Task.Delay(200, TestCancellationToken); + + Assert.Equal(1, count); // Should not have received msg2 + } + + [Fact] + public async Task PublishAsync_HeadersAreReadOnly() + { + using var bus = new InMemoryPubSubClient(); + + PubSubMessage? received = null; + var signal = new SemaphoreSlim(0); + + await using var sub = await bus.SubscribeAsync("topic", (msg, ct) => + { + received = msg; + signal.Release(); + return Task.CompletedTask; + }, TestCancellationToken); + + await bus.PublishAsync("topic", "test"u8.ToArray(), new Dictionary + { + ["h1"] = "v1", ["h2"] = "v2" + }, TestCancellationToken); + + Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.NotNull(received); + Assert.Equal("v1", received.Headers["h1"]); + Assert.Equal("v2", received.Headers["h2"]); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs new file mode 100644 index 00000000..0520c543 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs @@ -0,0 +1,289 @@ +using Foundatio.Mediator.Distributed; +using Microsoft.Extensions.Time.Testing; + +namespace Foundatio.Mediator.Distributed.Tests; + +public class InMemoryQueueClientTests(ITestOutputHelper output) : QueueClientTestBase(output) +{ + protected override IQueueClient CreateClient() => new InMemoryQueueClient(); + + [Fact] + public async Task MultipleQueues_AreIsolated() + { + var client = CreateClient(); + var q1 = $"queue-a-{Guid.NewGuid():N}"; + var q2 = $"queue-b-{Guid.NewGuid():N}"; + + await client.SendAsync(q1, new QueueEntry { Body = "a"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(q2, new QueueEntry { Body = "b"u8.ToArray() }, TestCancellationToken); + + var msgs1 = await client.ReceiveAsync(q1, 10, TestCancellationToken); + var msgs2 = await client.ReceiveAsync(q2, 10, TestCancellationToken); + + Assert.Single(msgs1); + Assert.Single(msgs2); + Assert.Equal("a"u8.ToArray(), msgs1[0].Body.ToArray()); + Assert.Equal("b"u8.ToArray(), msgs2[0].Body.ToArray()); + } + + [Fact] + public async Task AbandonAsync_IncrementsDequeueCount() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "retry-test"u8.ToArray() }, TestCancellationToken); + + // Receive, abandon, receive again β€” dequeue count should increase + var first = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Equal(1, first[0].DequeueCount); + + await client.AbandonAsync(first[0], cancellationToken: TestCancellationToken); + + var second = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Equal(2, second[0].DequeueCount); + + await client.AbandonAsync(second[0], cancellationToken: TestCancellationToken); + + var third = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Equal(3, third[0].DequeueCount); + } + + [Fact] + public async Task CompleteAsync_ThenReceive_ReturnsEmpty() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "complete-test"u8.ToArray() }, TestCancellationToken); + var msgs = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + await client.CompleteAsync(msgs[0], TestCancellationToken); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var remaining = await client.ReceiveAsync(queueName, 10, cts.Token); + Assert.Empty(remaining); + } + + [Fact] + public async Task SendBatchAsync_Ordering_PreservedApproximately() + { + var client = CreateClient(); + var queueName = TestQueueName; + + var entries = Enumerable.Range(0, 5).Select(i => new QueueEntry + { + Body = new byte[] { (byte)i } + }).ToList(); + + await client.SendBatchAsync(queueName, entries, TestCancellationToken); + + var received = new List(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (received.Count < 5 && !cts.IsCancellationRequested) + { + var batch = await client.ReceiveAsync(queueName, 10, cts.Token); + received.AddRange(batch); + } + + Assert.Equal(5, received.Count); + // In-memory channel preserves FIFO order + for (int i = 0; i < 5; i++) + Assert.Equal((byte)i, received[i].Body.Span[0]); + } + + [Fact] + public async Task ConcurrentSendAndReceive_AllMessagesDelivered() + { + var client = CreateClient(); + var queueName = TestQueueName; + const int messageCount = 100; + + // Send concurrently + var sendTasks = Enumerable.Range(0, messageCount).Select(i => + client.SendAsync(queueName, new QueueEntry { Body = new byte[] { (byte)(i % 256) } }, TestCancellationToken)); + await Task.WhenAll(sendTasks); + + // Receive all + var received = new List(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (received.Count < messageCount && !cts.IsCancellationRequested) + { + var batch = await client.ReceiveAsync(queueName, 50, cts.Token); + received.AddRange(batch); + } + + Assert.Equal(messageCount, received.Count); + } + + // ── Dead-letter ──────────────────────────────────────────────────── + + [Fact] + public async Task DeadLetterAsync_MovesMessageToDLQ() + { + var client = (InMemoryQueueClient)CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry + { + Body = "poison"u8.ToArray(), + Headers = new Dictionary { ["custom"] = "value" } + }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(messages); + + await client.DeadLetterAsync(messages[0], "Bad format", TestCancellationToken); + + // Original queue should be empty + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var remaining = await client.ReceiveAsync(queueName, 10, cts.Token); + Assert.Empty(remaining); + + // DLQ should have the message + var dlqMessages = client.DrainDeadLetterMessages(queueName); + Assert.Single(dlqMessages); + Assert.Equal("poison"u8.ToArray(), dlqMessages[0].Body.ToArray()); + } + + [Fact] + public async Task DeadLetterAsync_PreservesOriginalHeaders() + { + var client = (InMemoryQueueClient)CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry + { + Body = "test"u8.ToArray(), + Headers = new Dictionary + { + [MessageHeaders.MessageType] = "MyMessage", + ["custom-key"] = "custom-value" + } + }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + await client.DeadLetterAsync(messages[0], "Test reason", TestCancellationToken); + + var dlq = client.DrainDeadLetterMessages(queueName); + Assert.Single(dlq); + + // Original headers preserved + Assert.Equal("MyMessage", dlq[0].Headers[MessageHeaders.MessageType]); + Assert.Equal("custom-value", dlq[0].Headers["custom-key"]); + + // Dead-letter metadata added + Assert.Equal("Test reason", dlq[0].Headers[MessageHeaders.DeadLetterReason]); + Assert.True(dlq[0].Headers.ContainsKey(MessageHeaders.DeadLetteredAt)); + Assert.Equal(queueName, dlq[0].Headers[MessageHeaders.OriginalQueueName]); + Assert.True(dlq[0].Headers.ContainsKey(MessageHeaders.DeadLetterDequeueCount)); + } + + [Fact] + public async Task DeadLetterAsync_PreservesDequeueCount() + { + var client = (InMemoryQueueClient)CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "dlq-count"u8.ToArray() }, TestCancellationToken); + + // Receive and abandon twice to bump dequeue count + var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; + await client.AbandonAsync(msg, cancellationToken: TestCancellationToken); + msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; + await client.AbandonAsync(msg, cancellationToken: TestCancellationToken); + msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; + Assert.Equal(3, msg.DequeueCount); + + await client.DeadLetterAsync(msg, "Too many retries", TestCancellationToken); + + var dlq = client.DrainDeadLetterMessages(queueName); + Assert.Single(dlq); + Assert.Equal("3", dlq[0].Headers[MessageHeaders.DeadLetterDequeueCount]); + } + + [Fact] + public async Task GetDeadLetterCount_ReturnsCorrectCount() + { + var client = (InMemoryQueueClient)CreateClient(); + var queueName = TestQueueName; + + Assert.Equal(0, client.GetDeadLetterCount(queueName)); + + // Dead-letter three messages + for (int i = 0; i < 3; i++) + { + await client.SendAsync(queueName, new QueueEntry { Body = new byte[] { (byte)i } }, TestCancellationToken); + var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; + await client.DeadLetterAsync(msg, $"reason-{i}", TestCancellationToken); + } + + Assert.Equal(3, client.GetDeadLetterCount(queueName)); + } + + // ── Abandon with delay (FakeTimeProvider) ────────────────────────── + + [Fact] + public async Task AbandonAsync_WithDelay_MessageNotVisibleUntilTimeAdvances() + { + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var client = new InMemoryQueueClient(fakeTime); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "delayed"u8.ToArray() }, TestCancellationToken); + var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; + + // Start abandon with 30s delay β€” it will block on Task.Delay + var abandonTask = client.AbandonAsync(msg, TimeSpan.FromSeconds(30), TestCancellationToken); + + // Message should NOT be re-enqueued yet + Assert.False(abandonTask.IsCompleted); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + var empty = await client.ReceiveAsync(queueName, 1, cts.Token); + Assert.Empty(empty); + + // Advance time past the delay + fakeTime.Advance(TimeSpan.FromSeconds(31)); + await abandonTask; + + // Now the message should be available + var redelivered = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(redelivered); + Assert.Equal("delayed"u8.ToArray(), redelivered[0].Body.ToArray()); + } + + [Fact] + public async Task AbandonAsync_ZeroDelay_ImmediatelyRequeues() + { + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var client = new InMemoryQueueClient(fakeTime); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "instant"u8.ToArray() }, TestCancellationToken); + var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; + + await client.AbandonAsync(msg, cancellationToken: TestCancellationToken); + + var redelivered = await client.ReceiveAsync(queueName, 1, TestCancellationToken); + Assert.Single(redelivered); + Assert.Equal(2, redelivered[0].DequeueCount); + } + + [Fact] + public async Task Timestamps_UseFakeTimeProvider() + { + var fixedTime = new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); + var fakeTime = new FakeTimeProvider(fixedTime); + var client = new InMemoryQueueClient(fakeTime); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "time-test"u8.ToArray() }, TestCancellationToken); + + // Advance 5 minutes before receiving + fakeTime.Advance(TimeSpan.FromMinutes(5)); + + var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; + + Assert.Equal(fixedTime, msg.EnqueuedAt); + Assert.Equal(fixedTime + TimeSpan.FromMinutes(5), msg.DequeuedAt); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs new file mode 100644 index 00000000..354e5a98 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs @@ -0,0 +1,223 @@ +using Microsoft.Extensions.Time.Testing; + +namespace Foundatio.Mediator.Distributed.Tests; + +public class InMemoryQueueJobStateStoreTests +{ + private readonly FakeTimeProvider _time = new(DateTimeOffset.UtcNow); + + private InMemoryQueueJobStateStore CreateStore() => new(_time); + + private static CancellationToken CT => TestContext.Current.CancellationToken; + + private QueueJobState CreateJobState(string jobId = "job-1", string queueName = "TestQueue", QueueJobStatus status = QueueJobStatus.Queued) + { + return new QueueJobState + { + JobId = jobId, + QueueName = queueName, + MessageType = "TestMessage", + Status = status, + CreatedUtc = _time.GetUtcNow(), + LastUpdatedUtc = _time.GetUtcNow() + }; + } + + [Fact] + public async Task SetAndGet_RoundTrips() + { + var store = CreateStore(); + var state = CreateJobState(); + + await store.SetJobStateAsync(state, cancellationToken: CT); + + var retrieved = await store.GetJobStateAsync("job-1", CT); + Assert.NotNull(retrieved); + Assert.Equal("job-1", retrieved.JobId); + Assert.Equal("TestQueue", retrieved.QueueName); + Assert.Equal(QueueJobStatus.Queued, retrieved.Status); + } + + [Fact] + public async Task GetJobState_NonExistent_ReturnsNull() + { + var store = CreateStore(); + var result = await store.GetJobStateAsync("nonexistent", CT); + Assert.Null(result); + } + + [Fact] + public async Task SetJobState_UpdatesExisting() + { + var store = CreateStore(); + var state = CreateJobState(); + await store.SetJobStateAsync(state, cancellationToken: CT); + + state.Status = QueueJobStatus.Processing; + state.Progress = 50; + await store.SetJobStateAsync(state, cancellationToken: CT); + + var retrieved = await store.GetJobStateAsync("job-1", CT); + Assert.NotNull(retrieved); + Assert.Equal(QueueJobStatus.Processing, retrieved.Status); + Assert.Equal(50, retrieved.Progress); + } + + [Fact] + public async Task GetJobsByQueue_ReturnsMatchingJobs() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-2", "QueueA"), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-3", "QueueB"), cancellationToken: CT); + + var jobsA = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + Assert.Equal(2, jobsA.Count); + + var jobsB = await store.GetJobsByQueueAsync("QueueB", cancellationToken: CT); + Assert.Single(jobsB); + } + + [Fact] + public async Task GetJobsByQueue_OrdersByCreatedUtcDescending() + { + var store = CreateStore(); + var state1 = CreateJobState("job-1", "QueueA"); + await store.SetJobStateAsync(state1, cancellationToken: CT); + + _time.Advance(TimeSpan.FromMinutes(1)); + var state2 = CreateJobState("job-2", "QueueA"); + await store.SetJobStateAsync(state2, cancellationToken: CT); + + var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + Assert.Equal(2, jobs.Count); + Assert.Equal("job-2", jobs[0].JobId); // newer first + Assert.Equal("job-1", jobs[1].JobId); + } + + [Fact] + public async Task GetJobsByQueue_SupportsPagination() + { + var store = CreateStore(); + for (int i = 1; i <= 5; i++) + { + _time.Advance(TimeSpan.FromSeconds(i)); + await store.SetJobStateAsync(CreateJobState($"job-{i}", "QueueA"), cancellationToken: CT); + } + + var page1 = await store.GetJobsByQueueAsync("QueueA", skip: 0, take: 2, cancellationToken: CT); + Assert.Equal(2, page1.Count); + + var page2 = await store.GetJobsByQueueAsync("QueueA", skip: 2, take: 2, cancellationToken: CT); + Assert.Equal(2, page2.Count); + + var page3 = await store.GetJobsByQueueAsync("QueueA", skip: 4, take: 2, cancellationToken: CT); + Assert.Single(page3); + } + + [Fact] + public async Task RequestCancellation_SetsFlag() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + + var result = await store.RequestCancellationAsync("job-1", CT); + Assert.True(result); + + var isCancelled = await store.IsCancellationRequestedAsync("job-1", CT); + Assert.True(isCancelled); + } + + [Fact] + public async Task RequestCancellation_NonExistent_ReturnsFalse() + { + var store = CreateStore(); + var result = await store.RequestCancellationAsync("nonexistent", CT); + Assert.False(result); + } + + [Fact] + public async Task RequestCancellation_TerminalState_ReturnsFalse() + { + var store = CreateStore(); + var state = CreateJobState(status: QueueJobStatus.Completed); + await store.SetJobStateAsync(state, cancellationToken: CT); + + var result = await store.RequestCancellationAsync("job-1", CT); + Assert.False(result); + } + + [Fact] + public async Task IsCancellationRequested_NotRequested_ReturnsFalse() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + + var isCancelled = await store.IsCancellationRequestedAsync("job-1", CT); + Assert.False(isCancelled); + } + + [Fact] + public async Task RemoveJobState_RemovesEntry() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + + await store.RemoveJobStateAsync("job-1", CT); + + var result = await store.GetJobStateAsync("job-1", CT); + Assert.Null(result); + } + + [Fact] + public async Task RemoveJobState_ClearsCancellation() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + await store.RequestCancellationAsync("job-1", CT); + + await store.RemoveJobStateAsync("job-1", CT); + + var isCancelled = await store.IsCancellationRequestedAsync("job-1", CT); + Assert.False(isCancelled); + } + + [Fact] + public async Task ExpiredState_ReturnsNull() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), expiry: TimeSpan.FromMinutes(5), cancellationToken: CT); + + // Advance past expiry + _time.Advance(TimeSpan.FromMinutes(6)); + + var result = await store.GetJobStateAsync("job-1", CT); + Assert.Null(result); + } + + [Fact] + public async Task NonExpiredState_StillReturned() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), expiry: TimeSpan.FromMinutes(5), cancellationToken: CT); + + _time.Advance(TimeSpan.FromMinutes(3)); + + var result = await store.GetJobStateAsync("job-1", CT); + Assert.NotNull(result); + } + + [Fact] + public async Task ExpiredJobs_ExcludedFromQueueListing() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), expiry: TimeSpan.FromMinutes(1), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-2", "QueueA"), expiry: TimeSpan.FromMinutes(10), cancellationToken: CT); + + _time.Advance(TimeSpan.FromMinutes(2)); + + var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + Assert.Single(jobs); + Assert.Equal("job-2", jobs[0].JobId); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs b/tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs new file mode 100644 index 00000000..7b60b59a --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs @@ -0,0 +1,243 @@ +using System.Text; +using Foundatio.Mediator.Distributed; +using Foundatio.Xunit; + +namespace Foundatio.Mediator.Distributed.Tests; + +/// +/// Abstract base class containing shared test cases for any implementation. +/// Subclasses provide the concrete client instance via . +/// +public abstract class QueueClientTestBase(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + protected abstract IQueueClient CreateClient(); + + protected virtual string TestQueueName => $"test-queue-{Guid.NewGuid():N}"; + + // ── Send / Receive ───────────────────────────────────────────────────── + + [Fact] + public async Task SendAsync_ThenReceiveAsync_ReturnsMessage() + { + var client = CreateClient(); + var queueName = TestQueueName; + var body = """{"Name":"Test"}"""u8.ToArray(); + var headers = new Dictionary + { + [MessageHeaders.MessageType] = "TestMessage", + [MessageHeaders.EnqueuedAt] = DateTimeOffset.UtcNow.ToString("O") + }; + + await client.SendAsync(queueName, new QueueEntry + { + Body = body, + Headers = headers + }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + + Assert.Single(messages); + var msg = messages[0]; + Assert.Equal(queueName, msg.QueueName); + Assert.Equal(body, msg.Body.ToArray()); + Assert.Equal("TestMessage", msg.Headers[MessageHeaders.MessageType]); + Assert.False(string.IsNullOrEmpty(msg.Id)); + Assert.True(msg.DequeueCount >= 1); + } + + [Fact] + public async Task SendAsync_WithNoHeaders_RoundTripsBody() + { + var client = CreateClient(); + var queueName = TestQueueName; + var body = """{"Value":42}"""u8.ToArray(); + + await client.SendAsync(queueName, new QueueEntry { Body = body }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + + Assert.Single(messages); + Assert.Equal(body, messages[0].Body.ToArray()); + } + + [Fact] + public async Task ReceiveAsync_EmptyQueue_ReturnsEmptyList() + { + var client = CreateClient(); + var queueName = TestQueueName; + + // Use a short timeout CTS so we don't wait forever on empty queues + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var messages = await client.ReceiveAsync(queueName, 10, cts.Token); + + Assert.Empty(messages); + } + + [Fact] + public async Task ReceiveAsync_RespectsMaxCount() + { + var client = CreateClient(); + var queueName = TestQueueName; + + // Send 5 messages + for (int i = 0; i < 5; i++) + { + await client.SendAsync(queueName, new QueueEntry + { + Body = Encoding.UTF8.GetBytes($"message-{i}") + }, TestCancellationToken); + } + + // Request only 2 + var messages = await client.ReceiveAsync(queueName, 2, TestCancellationToken); + + Assert.True(messages.Count is >= 1 and <= 2, $"Expected 1-2 messages but got {messages.Count}"); + } + + // ── Complete ─────────────────────────────────────────────────────────── + + [Fact] + public async Task CompleteAsync_RemovesMessage() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "hello"u8.ToArray() }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + Assert.Single(messages); + + await client.CompleteAsync(messages[0], TestCancellationToken); + + // Queue should now be empty β€” use short timeout + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var remaining = await client.ReceiveAsync(queueName, 10, cts.Token); + Assert.Empty(remaining); + } + + // ── Abandon ──────────────────────────────────────────────────────────── + + [Fact] + public async Task AbandonAsync_RequeuesMessage() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "requeue-me"u8.ToArray() }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + Assert.Single(messages); + var original = messages[0]; + + await client.AbandonAsync(original, cancellationToken: TestCancellationToken); + + // Message should be available again + var redelivered = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + Assert.Single(redelivered); + Assert.Equal(original.Body.ToArray(), redelivered[0].Body.ToArray()); + Assert.True(redelivered[0].DequeueCount >= 2, + $"Expected dequeue count >= 2 after abandon, got {redelivered[0].DequeueCount}"); + } + + // ── RenewTimeout ─────────────────────────────────────────────────────── + + [Fact] + public async Task RenewTimeoutAsync_DoesNotThrow() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "timeout-test"u8.ToArray() }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + Assert.Single(messages); + + // Should not throw β€” just extends the visibility + await client.RenewTimeoutAsync(messages[0], TimeSpan.FromMinutes(1), TestCancellationToken); + + // Clean up + await client.CompleteAsync(messages[0], TestCancellationToken); + } + + // ── SendBatch ────────────────────────────────────────────────────────── + + [Fact] + public async Task SendBatchAsync_SendsAllMessages() + { + var client = CreateClient(); + var queueName = TestQueueName; + + var entries = Enumerable.Range(0, 3).Select(i => new QueueEntry + { + Body = Encoding.UTF8.GetBytes($"batch-{i}"), + Headers = new Dictionary { ["index"] = i.ToString() } + }).ToList(); + + await client.SendBatchAsync(queueName, entries, TestCancellationToken); + + // Receive all β€” may need multiple receives for SQS + var received = new List(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (received.Count < 3 && !cts.IsCancellationRequested) + { + var batch = await client.ReceiveAsync(queueName, 10, cts.Token); + received.AddRange(batch); + } + + Assert.Equal(3, received.Count); + } + + // ── Headers roundtrip ────────────────────────────────────────────────── + + [Fact] + public async Task Headers_RoundTrip() + { + var client = CreateClient(); + var queueName = TestQueueName; + + var headers = new Dictionary + { + [MessageHeaders.MessageType] = "MyApp.Commands.DoWork, MyApp", + [MessageHeaders.CorrelationId] = "corr-12345", + [MessageHeaders.EnqueuedAt] = "2026-03-29T12:00:00Z", + ["custom-header"] = "custom-value" + }; + + await client.SendAsync(queueName, new QueueEntry + { + Body = "{}"u8.ToArray(), + Headers = headers + }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + Assert.Single(messages); + + foreach (var (key, value) in headers) + { + Assert.True(messages[0].Headers.ContainsKey(key), $"Missing header: {key}"); + Assert.Equal(value, messages[0].Headers[key]); + } + } + + // ── Metadata ────────────────────────────────────────────────────── + + [Fact] + public async Task ReceivedMessage_HasMetadata() + { + var client = CreateClient(); + var queueName = TestQueueName; + + await client.SendAsync(queueName, new QueueEntry { Body = "meta-test"u8.ToArray() }, TestCancellationToken); + + var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); + Assert.Single(messages); + + var msg = messages[0]; + Assert.False(string.IsNullOrEmpty(msg.Id)); + Assert.Equal(queueName, msg.QueueName); + Assert.True(msg.DequeueCount >= 1); + // EnqueuedAt and DequeuedAt should be populated + Assert.True(msg.EnqueuedAt > DateTimeOffset.MinValue, "EnqueuedAt should be set"); + Assert.True(msg.DequeuedAt > DateTimeOffset.MinValue, "DequeuedAt should be set"); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerIntegrationTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerIntegrationTests.cs new file mode 100644 index 00000000..9cbfcd2d --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerIntegrationTests.cs @@ -0,0 +1,433 @@ +using System.Text.Json; +using Foundatio.Mediator.Distributed; +using Foundatio.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Foundatio.Mediator.Distributed.Tests; + +// ── Messages ───────────────────────────────────────────────────────── +public record QueuedCommand(string Value); +public record QueuedQuery(string Value); + +// ── Thread-safe signal for async handler completion ────────────────── +public class HandlerSignal +{ + private readonly SemaphoreSlim _semaphore = new(0); + private readonly List _values = []; + + public IReadOnlyList Values + { + get { lock (_values) return [.. _values]; } + } + + public void Record(string value) + { + lock (_values) _values.Add(value); + _semaphore.Release(); + } + + public async Task WaitAsync(int count = 1, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.FromSeconds(10); + for (int i = 0; i < count; i++) + { + if (!await _semaphore.WaitAsync(timeout.Value)) + throw new TimeoutException($"Timed out waiting for handler signal (expected {count}, got {i})"); + } + } +} + +// ── Queue handlers (DI-injected signal for test isolation) ─────────── + +[Queue] +public class QueuedCommandHandler(HandlerSignal signal) +{ + public void Handle(QueuedCommand message) + { + signal.Record(message.Value); + } +} + +[Queue] +public class QueuedQueryHandler(HandlerSignal signal) +{ + public string Handle(QueuedQuery message) + { + signal.Record(message.Value); + return $"Processed: {message.Value}"; + } +} + +// ── Tests ──────────────────────────────────────────────────────────── + +public class QueueWorkerIntegrationTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public async Task QueuedHandler_InvokeAsync_EnqueuesAndProcesses() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + // Start the hosted services (QueueWorker) + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + + // InvokeAsync should enqueue (not execute handler inline) + await mediator.InvokeAsync(new QueuedCommand("hello"), cts.Token); + + // Wait for the worker to process the message + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(10)); + + Assert.Single(signal.Values); + Assert.Equal("hello", signal.Values[0]); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task QueuedHandler_MultipleMessages_AllProcessed() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + + // Send multiple messages + for (int i = 0; i < 5; i++) + await mediator.InvokeAsync(new QueuedCommand($"msg-{i}"), cts.Token); + + // Wait for all to be processed + await signal.WaitAsync(count: 5, timeout: TimeSpan.FromSeconds(10)); + + Assert.Equal(5, signal.Values.Count); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task QueueMiddleware_EnqueuePath_SerializesCorrectly() + { + // Verify the enqueue path serializes the message correctly by directly reading from the queue + var signal = new HandlerSignal(); + var queueClient = new InMemoryQueueClient(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddSingleton(queueClient); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + // Do NOT start hosted services β€” just enqueue + await mediator.InvokeAsync(new QueuedCommand("serialize-test"), TestCancellationToken); + + // Read directly from the queue client + var messages = await queueClient.ReceiveAsync("QueuedCommand", 10, TestCancellationToken); + Assert.Single(messages); + + // Verify headers + Assert.True(messages[0].Headers.ContainsKey(MessageHeaders.MessageType)); + Assert.Contains("QueuedCommand", messages[0].Headers[MessageHeaders.MessageType]); + Assert.True(messages[0].Headers.ContainsKey(MessageHeaders.EnqueuedAt)); + + // Verify body deserializes back + var deserialized = JsonSerializer.Deserialize(messages[0].Body.Span); + Assert.NotNull(deserialized); + Assert.Equal("serialize-test", deserialized.Value); + } + + [Fact] + public async Task QueueWorker_InjectsQueueContext() + { + // Tests that QueueContext is available to handlers during processing + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + await mediator.InvokeAsync(new QueueContextCheck("ctx-test"), cts.Token); + + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(10)); + + Assert.True(QueueContextCheckHandler.HadQueueContext, "Handler should have received QueueContext"); + Assert.Equal("QueueContextCheck", QueueContextCheckHandler.ReceivedQueueName); + Assert.True(QueueContextCheckHandler.ReceivedDequeueCount >= 1); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } +} + +// ── Handler that checks for QueueContext injection ─────────────────── + +public record QueueContextCheck(string Value); + +[Queue] +public class QueueContextCheckHandler(HandlerSignal signal) +{ + public static bool HadQueueContext { get; set; } + public static string? ReceivedQueueName { get; set; } + public static int ReceivedDequeueCount { get; set; } + + public void Handle(QueueContextCheck message, QueueContext queueContext) + { + HadQueueContext = true; + ReceivedQueueName = queueContext.QueueName; + ReceivedDequeueCount = queueContext.DequeueCount; + signal.Record(message.Value); + } +} + +// ── Handler that always throws (for retry/dead-letter testing) ─────── + +public record PoisonMessage(string Value); + +[Queue(MaxRetries = 2, RetryPolicy = QueueRetryPolicy.None)] +public class PoisonMessageHandler(HandlerSignal signal) +{ + public void Handle(PoisonMessage message) + { + signal.Record($"attempt-{message.Value}"); + throw new InvalidOperationException("Simulated failure"); + } +} + +// ── Handler that fails N times then succeeds (transient failure) ───── + +public record TransientMessage(string Value); + +/// +/// Tracks how many times Handle has been called per message value. +/// Throws on the first call, succeeds on subsequent calls. +/// +public class TransientFailureTracker +{ + private int _callCount; + public int FailCount { get; set; } = 1; + public int CallCount => _callCount; + public int Increment() => Interlocked.Increment(ref _callCount); +} + +[Queue(MaxRetries = 2, RetryPolicy = QueueRetryPolicy.None)] +public class TransientMessageHandler(HandlerSignal signal, TransientFailureTracker tracker) +{ + public void Handle(TransientMessage message) + { + var attempt = tracker.Increment(); + signal.Record($"attempt-{attempt}"); + + if (attempt <= tracker.FailCount) + throw new InvalidOperationException($"Transient failure (attempt {attempt})"); + } +} + +// ── Handler for MaxRetries=0 (no retries, immediate dead-letter) ───── + +public record NoRetryMessage(string Value); + +[Queue(MaxRetries = 0, RetryPolicy = QueueRetryPolicy.None)] +public class NoRetryMessageHandler(HandlerSignal signal) +{ + public void Handle(NoRetryMessage message) + { + signal.Record($"attempt-{message.Value}"); + throw new InvalidOperationException("Always fails"); + } +} + +// ── Dead-letter integration tests ──────────────────────────────────── + +public class QueueWorkerDeadLetterTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public async Task FailedMessage_IsDeadLettered_AfterMaxRetries() + { + var signal = new HandlerSignal(); + var queueClient = new InMemoryQueueClient(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddSingleton(queueClient); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + await mediator.InvokeAsync(new PoisonMessage("test"), cts.Token); + + // Handler throws every time. MaxRetries=2 means 3 attempts (1 initial + 2 retries) + // then dead-lettered on the 4th receive. + // Wait for the handler to be called (up to 3 times) + dead-letter + await signal.WaitAsync(count: 3, timeout: TimeSpan.FromSeconds(10)); + + // Give the worker a moment to dead-letter after the 3rd failure + await Task.Delay(500, cts.Token); + + Assert.Equal(3, signal.Values.Count); + + // Verify message ended up in DLQ + var dlqCount = queueClient.GetDeadLetterCount("PoisonMessage"); + Assert.Equal(1, dlqCount); + + var dlqMessages = queueClient.DrainDeadLetterMessages("PoisonMessage"); + Assert.Single(dlqMessages); + Assert.Contains("max retries", dlqMessages[0].Headers[MessageHeaders.DeadLetterReason], StringComparison.OrdinalIgnoreCase); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task TransientFailure_SucceedsOnRetry_NotDeadLettered() + { + var signal = new HandlerSignal(); + var tracker = new TransientFailureTracker { FailCount = 1 }; + var queueClient = new InMemoryQueueClient(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddSingleton(tracker); + services.AddSingleton(queueClient); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + await mediator.InvokeAsync(new TransientMessage("transient"), cts.Token); + + // First attempt fails, second attempt succeeds + await signal.WaitAsync(count: 2, timeout: TimeSpan.FromSeconds(10)); + + // Give the worker a moment to process + await Task.Delay(500, cts.Token); + + Assert.Equal(2, tracker.CallCount); + Assert.Equal("attempt-1", signal.Values[0]); + Assert.Equal("attempt-2", signal.Values[1]); + + // Should NOT be dead-lettered + Assert.Equal(0, queueClient.GetDeadLetterCount("TransientMessage")); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task MaxRetriesZero_DeadLettersOnFirstFailure() + { + var signal = new HandlerSignal(); + var queueClient = new InMemoryQueueClient(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddSingleton(queueClient); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + await mediator.InvokeAsync(new NoRetryMessage("no-retry"), cts.Token); + + // MaxRetries=0 means 1 attempt only; handler is called once, then dead-lettered on 2nd receive + await signal.WaitAsync(count: 1, timeout: TimeSpan.FromSeconds(10)); + + // Give the worker time to dead-letter + await Task.Delay(500, cts.Token); + + Assert.Single(signal.Values); + + var dlqCount = queueClient.GetDeadLetterCount("NoRetryMessage"); + Assert.Equal(1, dlqCount); + + var dlqMessages = queueClient.DrainDeadLetterMessages("NoRetryMessage"); + Assert.Single(dlqMessages); + Assert.Contains("max retries", dlqMessages[0].Headers[MessageHeaders.DeadLetterReason], StringComparison.OrdinalIgnoreCase); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs new file mode 100644 index 00000000..4632d919 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs @@ -0,0 +1,359 @@ +using Foundatio.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Foundatio.Mediator.Distributed.Tests; + +// ── Messages ───────────────────────────────────────────────────────── + +public record TrackedCommand(string Value); + +public record TrackedLongRunningCommand(string Value, int Steps = 3); + +public record TrackedCancellableCommand(string Value); + +// ── Handlers ───────────────────────────────────────────────────────── + +[Queue(TrackProgress = true)] +public class TrackedCommandHandler(HandlerSignal signal) +{ + public void Handle(TrackedCommand message) + { + signal.Record(message.Value); + } +} + +[Queue(TrackProgress = true)] +public class TrackedLongRunningCommandHandler(HandlerSignal signal) +{ + public async Task HandleAsync(TrackedLongRunningCommand message, QueueContext queueContext, CancellationToken ct) + { + for (int i = 1; i <= message.Steps; i++) + { + await Task.Delay(50, ct).ConfigureAwait(false); + int percent = (int)((double)i / message.Steps * 100); + await queueContext.ReportProgressAsync(percent, $"Step {i}/{message.Steps}", ct).ConfigureAwait(false); + } + + signal.Record(message.Value); + } +} + +[Queue(TrackProgress = true)] +public class TrackedCancellableCommandHandler(HandlerSignal signal) +{ + public async Task HandleAsync(TrackedCancellableCommand message, QueueContext queueContext, CancellationToken ct) + { + // Simulate long-running work that checks cancellation via progress reporting + for (int i = 0; i < 100; i++) + { + await Task.Delay(100, ct).ConfigureAwait(false); + await queueContext.ReportProgressAsync(i, $"Working... {i}%", ct).ConfigureAwait(false); + } + + signal.Record(message.Value); + } +} + +// ── Tests ──────────────────────────────────────────────────────────── + +public class QueueWorkerJobTrackingTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public async Task TrackedHandler_EnqueueCreatesJobState() + { + var signal = new HandlerSignal(); + var queueClient = new InMemoryQueueClient(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddSingleton(queueClient); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + var stateStore = provider.GetRequiredService(); + + // Enqueue (don't start workers) β€” state should be created at enqueue time + await mediator.InvokeAsync(new TrackedCommand("test"), TestCancellationToken); + + // Read the queue message to get the jobId from headers + var messages = await queueClient.ReceiveAsync("TrackedCommand", 10, TestCancellationToken); + Assert.Single(messages); + Assert.True(messages[0].Headers.ContainsKey(MessageHeaders.JobId)); + + var jobId = messages[0].Headers[MessageHeaders.JobId]; + Assert.False(string.IsNullOrEmpty(jobId)); + + // Verify state store has the job in Queued status + var state = await stateStore.GetJobStateAsync(jobId, TestCancellationToken); + Assert.NotNull(state); + Assert.Equal(QueueJobStatus.Queued, state.Status); + Assert.Equal("TrackedCommand", state.QueueName); + } + + [Fact] + public async Task TrackedHandler_CompletedJobHasCorrectState() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + var stateStore = provider.GetRequiredService(); + + await mediator.InvokeAsync(new TrackedCommand("job-done"), cts.Token); + + // Wait for handler to execute + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(10)); + + // Give the worker a moment to update state after handler completes + await Task.Delay(200, cts.Token); + + // Find the job β€” there should be exactly one + var jobs = await stateStore.GetJobsByQueueAsync("TrackedCommand", cancellationToken: cts.Token); + Assert.Single(jobs); + + var state = jobs[0]; + Assert.Equal(QueueJobStatus.Completed, state.Status); + Assert.Equal(100, state.Progress); + Assert.NotNull(state.CompletedUtc); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task TrackedHandler_ProgressReporting_UpdatesState() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + var stateStore = provider.GetRequiredService(); + + await mediator.InvokeAsync(new TrackedLongRunningCommand("progress-test", Steps: 5), cts.Token); + + // Wait for completion + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(10)); + await Task.Delay(200, cts.Token); + + var jobs = await stateStore.GetJobsByQueueAsync("TrackedLongRunningCommand", cancellationToken: cts.Token); + Assert.Single(jobs); + + var state = jobs[0]; + Assert.Equal(QueueJobStatus.Completed, state.Status); + Assert.Equal(100, state.Progress); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task TrackedHandler_Cancellation_SetsStateAndCancelsToken() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + var stateStore = provider.GetRequiredService(); + + await mediator.InvokeAsync(new TrackedCancellableCommand("cancel-test"), cts.Token); + + // Wait a bit for the handler to start processing + await Task.Delay(500, cts.Token); + + // Find the job and request cancellation + var jobs = await stateStore.GetJobsByQueueAsync("TrackedCancellableCommand", cancellationToken: cts.Token); + Assert.Single(jobs); + var jobId = jobs[0].JobId; + + // Verify it's currently Processing + var state = await stateStore.GetJobStateAsync(jobId, cts.Token); + Assert.NotNull(state); + Assert.Equal(QueueJobStatus.Processing, state.Status); + + // Request cancellation + var cancelled = await stateStore.RequestCancellationAsync(jobId, cts.Token); + Assert.True(cancelled); + + // Wait for cancellation to propagate (default poll interval is 5s) + await Task.Delay(8000, cts.Token); + + // Verify the job state is now Cancelled + state = await stateStore.GetJobStateAsync(jobId, cts.Token); + Assert.NotNull(state); + Assert.Equal(QueueJobStatus.Cancelled, state.Status); + Assert.NotNull(state.CompletedUtc); + + // The handler should NOT have completed (signal should not have been recorded) + Assert.Empty(signal.Values); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task NonTrackedHandler_StillWorksNormally() + { + // Existing QueuedCommand (no TrackProgress) should still work without a state store + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + + await mediator.InvokeAsync(new QueuedCommand("compat-test"), cts.Token); + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(10)); + + Assert.Single(signal.Values); + Assert.Equal("compat-test", signal.Values[0]); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task WorkerRegistry_IsPopulated() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + + var workers = registry.GetWorkers(); + Assert.NotEmpty(workers); + + var worker = registry.GetWorker("TrackedCommand"); + Assert.NotNull(worker); + Assert.Equal("TrackedCommand", worker.QueueName); + Assert.True(worker.TrackProgress); + } + + [Fact] + public async Task WorkerInfo_TracksRuntimeStats() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + var hostedServices = provider.GetServices(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + foreach (var svc in hostedServices) + await svc.StartAsync(cts.Token); + + try + { + var mediator = provider.GetRequiredService(); + var registry = provider.GetRequiredService(); + + await mediator.InvokeAsync(new TrackedCommand("stats-1"), cts.Token); + await mediator.InvokeAsync(new TrackedCommand("stats-2"), cts.Token); + + await signal.WaitAsync(count: 2, timeout: TimeSpan.FromSeconds(10)); + await Task.Delay(200, cts.Token); + + var worker = registry.GetWorker("TrackedCommand"); + Assert.NotNull(worker); + Assert.Equal(2, worker.MessagesProcessed); + Assert.Equal(0, worker.MessagesFailed); + Assert.True(worker.IsRunning); + } + finally + { + foreach (var svc in hostedServices) + await svc.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task InMemoryStateStore_AutoRegistered_WhenTrackProgressEnabled() + { + var signal = new HandlerSignal(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(signal); + services.AddMediator(b => b.AddAssembly()); + services.AddMediatorDistributed(); + + await using var provider = services.BuildServiceProvider(); + + // Should auto-register InMemoryQueueJobStateStore + var stateStore = provider.GetService(); + Assert.NotNull(stateStore); + Assert.IsType(stateStore); + } +} diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt index a92ff60a..630212b3 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.DefaultStaticHandler_WithOTel.verified.txt @@ -39,7 +39,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(Ping)), "PingHandler_Ping_Handler", - (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(PingHandler_Ping_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), + (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(PingHandler_Ping_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), PingHandler_Ping_Handler.UntypedHandle, false, 2147483647, @@ -269,12 +269,13 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class PingHandler_Ping_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, Ping message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) + public static string Handle(Foundatio.Mediator.IMediator mediator, Ping message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) { var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("Ping"); + using var activity = MediatorActivitySource.Instance.StartActivity("Handle Ping"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "Ping"); + activity?.SetTag("messaging.handler", "PingHandler"); string? result = default!; System.Exception? exception = null; @@ -297,13 +298,14 @@ public static class PingHandler_Ping_Handler return result; } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) { var typedMessage = (Ping)message; var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("Ping"); + using var activity = MediatorActivitySource.Instance.StartActivity("Handle Ping"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "Ping"); + activity?.SetTag("messaging.handler", "PingHandler"); string? result = default!; System.Exception? exception = null; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt index b00db101..20166e05 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt @@ -39,7 +39,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(GetWidget)), "WidgetHandler_GetWidget_Handler", - (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(WidgetHandler_GetWidget_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), + (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(WidgetHandler_GetWidget_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), WidgetHandler_GetWidget_Handler.UntypedHandle, false, 2147483647, @@ -149,12 +149,12 @@ public static class Tests_MediatorEndpoints if (logEndpoints) { System.Action writeLog = System.Console.WriteLine; - writeLog("Foundatio.Mediator mapped 1 endpoint(s):"); + writeLog("Foundatio.Mediator mapped 1 endpoint(s) for Tests:"); writeLog(" GET /api/widgets/{id} β†’ WidgetHandler.Handle(GetWidget) (convention)"); } else { - System.Console.WriteLine("Foundatio.Mediator mapped 1 endpoint(s)."); + System.Console.WriteLine("Foundatio.Mediator mapped 1 endpoint(s) for Tests."); } } } @@ -380,14 +380,14 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class WidgetHandler_GetWidget_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, GetWidget message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) + public static string Handle(Foundatio.Mediator.IMediator mediator, GetWidget message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) { var serviceProvider = (System.IServiceProvider)mediator; var handlerInstance = GetOrCreateHandler(serviceProvider); return handlerInstance.Handle(message); } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) { var typedMessage = (GetWidget)message; var serviceProvider = (System.IServiceProvider)mediator; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt index 61816c04..0483b9eb 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.HandlerWithMiddlewarePipeline.verified.txt @@ -42,7 +42,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(ProcessOrder)), "ProcessOrderHandler_ProcessOrder_Handler", - (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), + (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle, false, 2147483647, @@ -272,7 +272,7 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class ProcessOrderHandler_ProcessOrder_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) + public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) { var serviceProvider = (System.IServiceProvider)mediator; var timingMiddleware = GetOrCreateTimingMiddleware(serviceProvider); @@ -306,7 +306,7 @@ public static class ProcessOrderHandler_ProcessOrder_Handler return result; } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) { var typedMessage = (ProcessOrder)message; var serviceProvider = (System.IServiceProvider)mediator; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt index b4392b36..a3486756 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.InterceptorGeneration.verified.txt @@ -39,7 +39,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(Echo)), "EchoHandler_Echo_Handler", - (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(EchoHandler_Echo_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), + (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(EchoHandler_Echo_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), EchoHandler_Echo_Handler.UntypedHandle, false, 2147483647, @@ -269,14 +269,14 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class EchoHandler_Echo_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, Echo message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) + public static string Handle(Foundatio.Mediator.IMediator mediator, Echo message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) { var serviceProvider = (System.IServiceProvider)mediator; var handlerInstance = GetOrCreateHandler(serviceProvider); return handlerInstance.Handle(message); } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) { var typedMessage = (Echo)message; var serviceProvider = (System.IServiceProvider)mediator; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt index 0c284966..f629166b 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.MiddlewareWithBeforeStateButNoFinally.verified.txt @@ -42,7 +42,7 @@ public static class Tests_MediatorHandlers registry.AddHandler(new HandlerRegistration( MessageTypeKey.Get(typeof(ProcessOrder)), "ProcessOrderHandler_ProcessOrder_Handler", - (mediator, message, callContext, cancellationToken, responseType) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType)), + (mediator, message, callContext, cancellationToken, responseType, skipAuthorization) => new ValueTask(ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle(mediator, message, callContext, cancellationToken, responseType, skipAuthorization)), ProcessOrderHandler_ProcessOrder_Handler.UntypedHandle, false, 2147483647, @@ -272,12 +272,13 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class ProcessOrderHandler_ProcessOrder_Handler { - public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) + public static string Handle(Foundatio.Mediator.IMediator mediator, ProcessOrder message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) { var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("ProcessOrder"); + using var activity = MediatorActivitySource.Instance.StartActivity("Handle ProcessOrder"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "ProcessOrder"); + activity?.SetTag("messaging.handler", "ProcessOrderHandler"); var loggingMiddleware = GetOrCreateLoggingMiddleware(serviceProvider); System.Diagnostics.Stopwatch? loggingMiddlewareResult = null; @@ -308,13 +309,14 @@ public static class ProcessOrderHandler_ProcessOrder_Handler return result; } - public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) + public static object? UntypedHandle(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) { var typedMessage = (ProcessOrder)message; var serviceProvider = (System.IServiceProvider)mediator; - using var activity = MediatorActivitySource.Instance.StartActivity("ProcessOrder"); + using var activity = MediatorActivitySource.Instance.StartActivity("Handle ProcessOrder"); activity?.SetTag("messaging.system", "Foundatio.Mediator"); activity?.SetTag("messaging.message.type", "ProcessOrder"); + activity?.SetTag("messaging.handler", "ProcessOrderHandler"); var loggingMiddleware = GetOrCreateLoggingMiddleware(serviceProvider); System.Diagnostics.Stopwatch? loggingMiddlewareResult = null; diff --git a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt index e1df7d33..35c2a39b 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.ScopedDIHandler_NoOTel.verified.txt @@ -270,7 +270,7 @@ namespace Foundatio.Mediator.Generated; [ExcludeFromCodeCoverage] public static class GetUserHandler_GetUser_Handler { - public static async System.Threading.Tasks.ValueTask HandleAsync(Foundatio.Mediator.IMediator mediator, GetUser message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken) + public static async System.Threading.Tasks.ValueTask HandleAsync(Foundatio.Mediator.IMediator mediator, GetUser message, Foundatio.Mediator.CallContext? callContext, System.Threading.CancellationToken cancellationToken, bool skipAuthorization = false) { var serviceProvider = (System.IServiceProvider)mediator; string? result = default!; @@ -279,7 +279,7 @@ public static class GetUserHandler_GetUser_Handler return result; } - public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType) + public static async ValueTask UntypedHandleAsync(IMediator mediator, object message, Foundatio.Mediator.CallContext? callContext, CancellationToken cancellationToken, Type? responseType, bool skipAuthorization = false) { var typedMessage = (GetUser)message; var serviceProvider = (System.IServiceProvider)mediator; From bb048a87b57d44bd677597f29cf192787e89ca4e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 4 Apr 2026 02:13:15 -0500 Subject: [PATCH 04/27] Revert performance.md and benchmark CSV to main --- ...tor.Benchmarks.FoundatioBenchmarks-report.csv | 16 ++++++++-------- docs/guide/performance.md | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv b/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv index fe44cb53..6d5d0692 100644 --- a/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv +++ b/benchmarks/Foundatio.Mediator.Benchmarks/BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.FoundatioBenchmarks-report.csv @@ -1,8 +1,8 @@ -Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Median,Gen0,Allocated -Command,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,0.0131 ns,0.0076 ns,0.0067 ns,0.0103 ns,0.0000,0 B -Query,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,22.2566 ns,0.1766 ns,0.1474 ns,22.2179 ns,0.0094,48 B -Publish,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,19.1801 ns,0.1982 ns,0.1854 ns,19.1668 ns,0.0000,0 B -FullQuery,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,80.3133 ns,0.6438 ns,0.5707 ns,80.1793 ns,0.0172,88 B -CascadingMessages,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,46.1951 ns,0.2226 ns,0.1973 ns,46.2104 ns,0.0141,72 B -ShortCircuit,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,4.8048 ns,0.0512 ns,0.0479 ns,4.7965 ns,0.0000,0 B -ExecuteMiddleware,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,True,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,37.7127 ns,0.4727 ns,0.4191 ns,37.8504 ns,0.0054,160 B +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Allocated +Command,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,0.0225 ns,0.0089 ns,0.0084 ns,0.0000,0 B +Query,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,21.1025 ns,0.0860 ns,0.0718 ns,0.0010,48 B +Publish,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,13.4689 ns,0.1782 ns,0.1667 ns,0.0000,0 B +FullQuery,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,70.9998 ns,0.2518 ns,0.2355 ns,0.0017,88 B +CascadingMessages,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,44.1354 ns,0.2387 ns,0.2233 ns,0.0014,72 B +ShortCircuit,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,4.9373 ns,0.0502 ns,0.0470 ns,0.0000,0 B +ExecuteMiddleware,DefaultJob,False,Default,Default,Default,Default,Default,Default,1111111111111111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,34.5473 ns,0.1470 ns,0.1303 ns,0.0032,160 B diff --git a/docs/guide/performance.md b/docs/guide/performance.md index 9e2a1309..22f055a0 100644 --- a/docs/guide/performance.md +++ b/docs/guide/performance.md @@ -4,7 +4,7 @@ Foundatio.Mediator aims to get as close to direct method call performance as pos ## Benchmark Results -> πŸ“Š **Last Updated:** 2026-03-29 +> πŸ“Š **Last Updated:** 2026-02-23 ### Commands @@ -15,7 +15,7 @@ Process a message with no return value. MethodMeanAllocated Direct_Command0.0000 ns0 B -Foundatio_Command0.0131 ns0 B +Foundatio_Command0.0584 ns0 B MediatorNet_Command8.4553 ns0 B ImmediateHandlers_Command11.0105 ns0 B MediatR_Command32.3613 ns128 B @@ -33,7 +33,7 @@ Request/response dispatch returning an Order object. MethodMeanAllocated Direct_Query21.1054 ns48 B -Foundatio_Query22.2566 ns48 B +Foundatio_Query22.7625 ns48 B MediatorNet_Query25.0262 ns48 B ImmediateHandlers_Query29.5762 ns48 B MediatR_Query53.4603 ns248 B @@ -53,7 +53,7 @@ Notification dispatched to 2 handlers. Direct_Publish0.0052 ns0 B MediatorNet_Publish5.6175 ns0 B -Foundatio_Publish19.1801 ns0 B +Foundatio_Publish16.2971 ns0 B ImmediateHandlers_Publish51.8625 ns32 B MediatR_Publish52.5791 ns440 B Wolverine_Publish1,755.3777 ns2,840 B @@ -72,7 +72,7 @@ Query where handler has an injected service (IOrderService) and timing middlewar Direct_FullQuery62.9251 ns160 B MediatorNet_FullQuery73.6510 ns88 B ImmediateHandlers_FullQuery74.1282 ns88 B -Foundatio_FullQuery80.3133 ns88 B +Foundatio_FullQuery77.3365 ns88 B Wolverine_FullQuery284.2368 ns944 B MassTransit_FullQuery5,914.7751 ns13,144 B @@ -88,7 +88,7 @@ CreateOrder returns an Order and publishes OrderCreatedEvent to 2 handlers. Foun Direct_CascadingMessages26.9792 ns144 B MediatorNet_CascadingMessages36.7020 ns72 B -Foundatio_CascadingMessages46.1951 ns72 B +Foundatio_CascadingMessages53.6296 ns72 B ImmediateHandlers_CascadingMessages82.9136 ns104 B MediatR_CascadingMessages113.6711 ns744 B Wolverine_CascadingMessages2,220.3872 ns4,056 B @@ -105,7 +105,7 @@ Middleware returns cached result; handler is never invoked. Each library uses it MethodMeanAllocated Direct_ShortCircuit0.2052 ns0 B -Foundatio_ShortCircuit4.8048 ns0 B +Foundatio_ShortCircuit5.1399 ns0 B MediatorNet_ShortCircuit8.2942 ns0 B ImmediateHandlers_ShortCircuit9.0116 ns0 B MediatR_ShortCircuit48.3730 ns416 B From b3a42fbd0bfa80f67fb46baaf2da72c7b7506275 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 4 Apr 2026 02:13:58 -0500 Subject: [PATCH 05/27] Rename ClientEventStreamHandler.cs to EventHandler.cs to match class name --- .../Api/Handlers/{ClientEventStreamHandler.cs => EventHandler.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename samples/CleanArchitectureSample/src/Api/Handlers/{ClientEventStreamHandler.cs => EventHandler.cs} (100%) diff --git a/samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs b/samples/CleanArchitectureSample/src/Api/Handlers/EventHandler.cs similarity index 100% rename from samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs rename to samples/CleanArchitectureSample/src/Api/Handlers/EventHandler.cs From f2075e376e706e535aa9f1507e0696aeb91a7003 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 4 Apr 2026 02:44:37 -0500 Subject: [PATCH 06/27] Apply code review fixes for distributed abstractions - Security: Add MessageTypeResolver allowlist for type deserialization, replacing unsafe Type.GetType() in DistributedNotificationWorker - Add PubSubEntry type to align IPubSubClient.PublishAsync with QueueEntry pattern - Add IAsyncDisposable to IQueueClient and IPubSubClient interfaces - Make IQueueClient.DeadLetterAsync a required method (no silent default) - Replace ContinueWith with async/await in IQueueJobStateStore defaults - Cache queue metadata in QueueMiddleware via ConcurrentDictionary keyed by DescriptorId, eliminating per-call reflection - Thread CancellationToken through QueueMiddleware to SendAsync/SetJobStateAsync - Replace lock(Random) with Random.Shared in QueueWorker retry delay - Rename DistributedOptions to DistributedQueueOptions for clarity - Rename AddSnsSqsPubSubClient to AddMediatorSnsSqsPubSub for naming consistency - Update all test files for new PubSubEntry API --- samples/CleanArchitectureSample/README.md | 2 +- .../src/Api/Program.cs | 2 +- .../SnsSqsPubSubClient.cs | 6 +-- .../SqsServiceExtensions.cs | 4 +- .../DistributedNotificationWorker.cs | 9 ++-- .../DistributedServiceExtensions.cs | 43 ++++++++++++--- .../IPubSubClient.cs | 10 ++-- .../IQueueClient.cs | 8 +-- .../IQueueJobStateStore.cs | 26 ++++----- .../InMemoryPubSubClient.cs | 20 ++++--- .../MessageTypeResolver.cs | 43 +++++++++++++++ .../PubSubMessage.cs | 16 ++++++ .../QueueMiddleware.cs | 53 ++++++++++--------- .../QueueWorker.cs | 9 +--- .../SnsSqsPubSubClientTests.cs | 14 ++--- ...DistributedNotificationIntegrationTests.cs | 4 +- .../InMemoryPubSubClientTests.cs | 17 +++--- 17 files changed, 190 insertions(+), 96 deletions(-) create mode 100644 src/Foundatio.Mediator.Distributed/MessageTypeResolver.cs diff --git a/samples/CleanArchitectureSample/README.md b/samples/CleanArchitectureSample/README.md index 7a0d0a34..55a276ef 100644 --- a/samples/CleanArchitectureSample/README.md +++ b/samples/CleanArchitectureSample/README.md @@ -452,7 +452,7 @@ builder.Services.AddMediatorSqs(); builder.Services.AddMediatorDistributed(); // Distributed notification fan-out via SNS+SQS -builder.Services.AddSnsSqsPubSubClient(); +builder.Services.AddMediatorSnsSqsPubSub(); builder.Services.AddMediatorDistributedNotifications(); ``` diff --git a/samples/CleanArchitectureSample/src/Api/Program.cs b/samples/CleanArchitectureSample/src/Api/Program.cs index 8e616144..f28ba67c 100644 --- a/samples/CleanArchitectureSample/src/Api/Program.cs +++ b/samples/CleanArchitectureSample/src/Api/Program.cs @@ -133,5 +133,5 @@ static void ConfigureAwsDistributed(IServiceCollection services, string serviceU _ => new Amazon.SimpleNotificationService.AmazonSimpleNotificationServiceClient(credentials, snsConfig)); services.AddMediatorSqs(); - services.AddSnsSqsPubSubClient(); + services.AddMediatorSnsSqsPubSub(); } diff --git a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs index af74ad48..bdcf795b 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs @@ -39,15 +39,15 @@ public SnsSqsPubSubClient( } /// - public async Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default) + public async Task PublishAsync(string topic, PubSubEntry message, CancellationToken cancellationToken = default) { var topicArn = await GetOrCreateTopicArnAsync(topic, cancellationToken).ConfigureAwait(false); // Wrap body + headers into a single JSON envelope for SNS var envelope = new MessageEnvelope { - Body = Convert.ToBase64String(body.Span), - Headers = headers is not null ? new Dictionary(headers) : null + Body = Convert.ToBase64String(message.Body.Span), + Headers = message.Headers is not null ? new Dictionary(message.Headers) : null }; var json = JsonSerializer.Serialize(envelope); diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs index 50d19e3d..c0fc9148 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs @@ -58,11 +58,11 @@ public static IServiceCollection AddMediatorSqs( /// /// services.AddSingleton<IAmazonSQS>(new AmazonSQSClient(...)); /// services.AddSingleton<IAmazonSimpleNotificationService>(new AmazonSimpleNotificationServiceClient(...)); - /// services.AddSnsSqsPubSubClient(); + /// services.AddMediatorSnsSqsPubSub(); /// services.AddMediatorDistributedNotifications(); /// /// - public static IServiceCollection AddSnsSqsPubSubClient( + public static IServiceCollection AddMediatorSnsSqsPubSub( this IServiceCollection services, Action? configure = null) { diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs index 20e5b8f9..642ba95c 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs @@ -31,6 +31,7 @@ public sealed class DistributedNotificationWorker : BackgroundService private readonly DistributedNotificationOptions _options; private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger _logger; + private readonly MessageTypeResolver? _typeResolver; /// /// Tracks notification objects that arrived from the bus and are currently being @@ -46,6 +47,7 @@ public DistributedNotificationWorker( IPubSubClient bus, DistributedNotificationOptions options, ILogger logger, + MessageTypeResolver? typeResolver = null, TimeProvider? timeProvider = null) { _scopeFactory = scopeFactory; @@ -53,6 +55,7 @@ public DistributedNotificationWorker( _options = options; _jsonOptions = options.JsonSerializerOptions ?? JsonSerializerOptions.Default; _logger = logger; + _typeResolver = typeResolver; _timeProvider = timeProvider ?? TimeProvider.System; } @@ -125,7 +128,7 @@ private async Task RunOutboundLoopAsync(CancellationToken stoppingToken) headers[MessageHeaders.TraceState] = traceState; } - await _bus.PublishAsync(_options.Topic, body, headers, stoppingToken).ConfigureAwait(false); + await _bus.PublishAsync(_options.Topic, new PubSubEntry { Body = body, Headers = headers }, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -186,10 +189,10 @@ private async Task ProcessInboundMessageAsync(PubSubMessage message, Cancellatio return; } - var messageType = Type.GetType(typeName); + var messageType = _typeResolver?.TryResolve(typeName); if (messageType is null) { - _logger.LogWarning("Cannot resolve type '{TypeName}' from bus message, skipping", typeName); + _logger.LogWarning("Cannot resolve type '{TypeName}' from bus message β€” type not registered in MessageTypeResolver, skipping", typeName); return; } diff --git a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs index 47b3190b..570d3bd3 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs @@ -8,7 +8,7 @@ namespace Foundatio.Mediator.Distributed; /// /// Options for configuring distributed queue support. /// -public class DistributedOptions +public class DistributedQueueOptions { /// /// Custom JSON serializer options for message serialization/deserialization. @@ -34,7 +34,7 @@ public static class DistributedServiceExtensions /// serialized and sent to a queue for asynchronous processing. /// /// The service collection. - /// Optional configuration callback for . + /// Optional configuration callback for . /// The service collection for chaining. /// /// @@ -48,7 +48,7 @@ public static class DistributedServiceExtensions /// public static IServiceCollection AddMediatorDistributed( this IServiceCollection services, - Action? configure = null) + Action? configure = null) { // Prevent double registration if (services.Any(sd => sd.ServiceType == typeof(QueueMiddleware))) @@ -58,7 +58,7 @@ public static IServiceCollection AddMediatorDistributed( ?? throw new InvalidOperationException( "AddMediatorDistributed requires AddMediator to be called first."); - var options = new DistributedOptions(); + var options = new DistributedQueueOptions(); configure?.Invoke(options); // Register options as singleton for QueueMiddleware and QueueWorker to consume @@ -75,9 +75,10 @@ public static IServiceCollection AddMediatorDistributed( // Register the middleware services.AddTransient(); - // Register the worker registry + // Register the worker registry and type resolver var workerRegistry = new QueueWorkerRegistry(); services.AddSingleton(workerRegistry); + var typeResolver = GetOrAddTypeResolver(services); // Track whether any handler uses progress tracking bool anyTrackProgress = false; @@ -97,6 +98,9 @@ public static IServiceCollection AddMediatorDistributed( ? queueAttr!.QueueName! : messageType.Name; + // Register this message type in the type resolver for safe deserialization + typeResolver.Register(messageType); + // Apply group filtering var group = queueAttr?.Group; if (options.Group is not null && !string.Equals(options.Group, group, StringComparison.OrdinalIgnoreCase)) @@ -153,7 +157,7 @@ public static IServiceCollection AddMediatorDistributed( sp.GetRequiredService(), sp.GetRequiredService(), workerOptions, - sp.GetService(), + sp.GetService(), sp.GetRequiredService>(), workerInfo, sp.GetService(), @@ -215,7 +219,21 @@ public static IServiceCollection AddMediatorDistributedNotifications( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService>())); + sp.GetRequiredService>(), + sp.GetService(), + sp.GetService())); + + // Register known notification types in the type resolver + var registry = services.GetHandlerRegistry(); + if (registry is not null) + { + var typeResolver = GetOrAddTypeResolver(services); + foreach (var reg in registry.Registrations) + { + if (reg.MessageType is not null && typeof(IDistributedNotification).IsAssignableFrom(reg.MessageType)) + typeResolver.Register(reg.MessageType); + } + } return services; } @@ -238,4 +256,15 @@ private static DistributedInfrastructureOptions GetOrAddInfrastructureOptions(IS return infraOptions; } + + private static MessageTypeResolver GetOrAddTypeResolver(IServiceCollection services) + { + var descriptor = services.FirstOrDefault(sd => sd.ServiceType == typeof(MessageTypeResolver)); + if (descriptor?.ImplementationInstance is MessageTypeResolver existing) + return existing; + + var resolver = new MessageTypeResolver(); + services.AddSingleton(resolver); + return resolver; + } } diff --git a/src/Foundatio.Mediator.Distributed/IPubSubClient.cs b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs index 95e66e86..f37f9709 100644 --- a/src/Foundatio.Mediator.Distributed/IPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs @@ -4,16 +4,15 @@ namespace Foundatio.Mediator.Distributed; /// Transport-agnostic pub/sub abstraction used by the distributed notification system. /// Implementations fan messages out to all subscribers (topic-based publish/subscribe). /// -public interface IPubSubClient +public interface IPubSubClient : IAsyncDisposable { /// /// Publishes a message to all subscribers of the specified topic. /// /// The topic to publish to. - /// The serialized message body. - /// Optional transport headers. + /// The outbound message containing body and optional headers. /// A cancellation token. - Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default); + Task PublishAsync(string topic, PubSubEntry message, CancellationToken cancellationToken = default); /// /// Subscribes to a topic. The returned unsubscribes when disposed. @@ -30,4 +29,7 @@ public interface IPubSubClient /// can skip to polling without additional API calls. /// Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + ValueTask IAsyncDisposable.DisposeAsync() => default; } diff --git a/src/Foundatio.Mediator.Distributed/IQueueClient.cs b/src/Foundatio.Mediator.Distributed/IQueueClient.cs index e6a64eb4..6e35368e 100644 --- a/src/Foundatio.Mediator.Distributed/IQueueClient.cs +++ b/src/Foundatio.Mediator.Distributed/IQueueClient.cs @@ -4,7 +4,7 @@ namespace Foundatio.Mediator.Distributed; /// Transport-agnostic contract for sending and receiving queue messages. /// Implementations map to specific transports (in-memory, SQS, RabbitMQ, etc.). /// -public interface IQueueClient +public interface IQueueClient : IAsyncDisposable { /// /// Sends a single message to the specified queue. @@ -49,8 +49,7 @@ public interface IQueueClient /// The message to dead-letter. /// A human-readable reason for dead-lettering. /// A cancellation token. - Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default) - => CompleteAsync(message, cancellationToken); + Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default); /// /// Ensures the specified queues exist, creating them if necessary. @@ -64,4 +63,7 @@ Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken canc /// Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) => Task.FromResult(new QueueStats { QueueName = queueName }); + + /// + ValueTask IAsyncDisposable.DisposeAsync() => default; } diff --git a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs index 69fefdb2..e9a1e124 100644 --- a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs @@ -64,28 +64,24 @@ Task> GetCountersAsync(string queueName, Cance /// /// Retrieves tracked jobs filtered by one or more statuses, ordered by creation time descending. /// - Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + async Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) { // Default: fall back to GetJobsByQueueAsync and filter in memory - return GetJobsByQueueAsync(queueName, 0, skip + take + 500, cancellationToken) - .ContinueWith(t => - { - var statusSet = new HashSet(statuses); - IReadOnlyList result = t.Result - .Where(j => statusSet.Contains(j.Status)) - .Skip(skip) - .Take(take) - .ToList(); - return result; - }, TaskContinuationOptions.OnlyOnRanToCompletion); + var all = await GetJobsByQueueAsync(queueName, 0, skip + take + 500, cancellationToken).ConfigureAwait(false); + var statusSet = new HashSet(statuses); + return all + .Where(j => statusSet.Contains(j.Status)) + .Skip(skip) + .Take(take) + .ToList(); } /// /// Counts jobs in a specific status for a queue. /// - Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + async Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) { - return GetJobsByQueueAsync(queueName, 0, int.MaxValue, cancellationToken) - .ContinueWith(t => (long)t.Result.Count(j => j.Status == status), TaskContinuationOptions.OnlyOnRanToCompletion); + var all = await GetJobsByQueueAsync(queueName, 0, int.MaxValue, cancellationToken).ConfigureAwait(false); + return all.Count(j => j.Status == status); } } diff --git a/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs index fcd9a985..40442d12 100644 --- a/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs @@ -13,21 +13,21 @@ public sealed class InMemoryPubSubClient : IPubSubClient, IDisposable private readonly ConcurrentDictionary> _subscriptions = new(); /// - public Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default) + public Task PublishAsync(string topic, PubSubEntry entry, CancellationToken cancellationToken = default) { if (!_subscriptions.TryGetValue(topic, out var entries)) return Task.CompletedTask; var message = new PubSubMessage { - Body = body, - Headers = headers is IReadOnlyDictionary ro - ? ro - : new Dictionary(headers ?? new Dictionary()) + Body = entry.Body, + Headers = entry.Headers is not null + ? new Dictionary(entry.Headers) + : new Dictionary() }; - foreach (var entry in entries.Values) - entry.Writer.TryWrite(message); + foreach (var sub in entries.Values) + sub.Writer.TryWrite(message); return Task.CompletedTask; } @@ -84,6 +84,12 @@ public void Dispose() _subscriptions.Clear(); } + public ValueTask DisposeAsync() + { + Dispose(); + return default; + } + private sealed class SubscriptionEntry(ChannelWriter writer) { public ChannelWriter Writer => writer; diff --git a/src/Foundatio.Mediator.Distributed/MessageTypeResolver.cs b/src/Foundatio.Mediator.Distributed/MessageTypeResolver.cs new file mode 100644 index 00000000..113b35e2 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/MessageTypeResolver.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Resolves message types from assembly-qualified type names using a pre-registered allowlist. +/// Prevents arbitrary type loading from untrusted message headers. +/// +/// +/// The resolver is populated during DI registration from handler registrations and known +/// notification types. Only types that have been explicitly registered can be deserialized. +/// +public sealed class MessageTypeResolver +{ + private readonly ConcurrentDictionary _allowedTypes = new(StringComparer.Ordinal); + + /// + /// Registers a type as allowed for deserialization. + /// + public void Register(Type type) + { + var key = type.AssemblyQualifiedName; + if (key is not null) + _allowedTypes.TryAdd(key, type); + + // Also register by full name for resilience against assembly version changes + var fullName = type.FullName; + if (fullName is not null) + _allowedTypes.TryAdd(fullName, type); + } + + /// + /// Attempts to resolve a type from a type name. Returns null if the type + /// is not in the allowlist. + /// + public Type? TryResolve(string typeName) + { + if (_allowedTypes.TryGetValue(typeName, out var type)) + return type; + + return null; + } +} diff --git a/src/Foundatio.Mediator.Distributed/PubSubMessage.cs b/src/Foundatio.Mediator.Distributed/PubSubMessage.cs index d95f7748..10628d7d 100644 --- a/src/Foundatio.Mediator.Distributed/PubSubMessage.cs +++ b/src/Foundatio.Mediator.Distributed/PubSubMessage.cs @@ -1,5 +1,21 @@ namespace Foundatio.Mediator.Distributed; +/// +/// Represents an outbound message to be published to a topic. +/// +public sealed class PubSubEntry +{ + /// + /// The serialized message body. + /// + public required ReadOnlyMemory Body { get; init; } + + /// + /// Optional metadata headers. Well-known keys are defined in . + /// + public Dictionary? Headers { get; init; } +} + /// /// A message received from the pub/sub client. /// diff --git a/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs index 394f8e26..fc31727f 100644 --- a/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs +++ b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; @@ -24,12 +25,15 @@ public class QueueMiddleware { private readonly IQueueClient _client; private readonly IQueueJobStateStore? _stateStore; + private readonly HandlerRegistry _registry; private readonly JsonSerializerOptions _jsonOptions; private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _metadataCache = new(StringComparer.Ordinal); - public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, IQueueJobStateStore? stateStore = null, TimeProvider? timeProvider = null) + public QueueMiddleware(IQueueClient client, HandlerRegistry registry, DistributedQueueOptions? options = null, IQueueJobStateStore? stateStore = null, TimeProvider? timeProvider = null) { _client = client; + _registry = registry; _stateStore = stateStore; _jsonOptions = options?.JsonSerializerOptions ?? JsonSerializerOptions.Default; _timeProvider = timeProvider ?? TimeProvider.System; @@ -39,7 +43,8 @@ public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, object message, HandlerExecutionDelegate next, HandlerExecutionInfo handlerInfo, - CallContext? callContext) + CallContext? callContext, + CancellationToken cancellationToken) { // Process path: QueueContext in CallContext signals we're processing from the queue if (callContext?.TryGet(out _) == true) @@ -53,7 +58,7 @@ public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, // Enqueue path: serialize and send to the queue var messageType = message.GetType(); var body = JsonSerializer.SerializeToUtf8Bytes(message, messageType, _jsonOptions); - var queueName = GetQueueName(handlerInfo, messageType); + var metadata = GetMetadata(handlerInfo.DescriptorId, messageType); var headers = new Dictionary { @@ -72,8 +77,7 @@ public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, // Generate job ID and track initial state when progress tracking is enabled string? jobId = null; - var trackProgress = IsTrackProgressEnabled(handlerInfo); - if (trackProgress && _stateStore is not null) + if (metadata.TrackProgress && _stateStore is not null) { jobId = Guid.NewGuid().ToString("N"); headers[MessageHeaders.JobId] = jobId; @@ -82,14 +86,14 @@ public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, var jobState = new QueueJobState { JobId = jobId, - QueueName = queueName, + QueueName = metadata.QueueName, MessageType = messageType.FullName ?? messageType.Name, Status = QueueJobStatus.Queued, CreatedUtc = now, LastUpdatedUtc = now }; - await _stateStore.SetJobStateAsync(jobState, cancellationToken: default).ConfigureAwait(false); + await _stateStore.SetJobStateAsync(jobState, cancellationToken: cancellationToken).ConfigureAwait(false); } var entry = new QueueEntry @@ -98,7 +102,7 @@ public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, Headers = headers }; - await _client.SendAsync(queueName, entry, default).ConfigureAwait(false); + await _client.SendAsync(metadata.QueueName, entry, cancellationToken).ConfigureAwait(false); if (jobId is not null) return Result.Accepted(jobId); @@ -106,25 +110,22 @@ public QueueMiddleware(IQueueClient client, DistributedOptions? options = null, return Result.Accepted("Message queued"); } - private static string GetQueueName(HandlerExecutionInfo handlerInfo, Type messageType) + private QueueHandlerMetadata GetMetadata(string descriptorId, Type messageType) { - var queueAttr = handlerInfo.HandlerType - .GetCustomAttributes(typeof(QueueAttribute), true) - .OfType() - .FirstOrDefault(); - - return !string.IsNullOrWhiteSpace(queueAttr?.QueueName) - ? queueAttr!.QueueName! - : messageType.Name; + return _metadataCache.GetOrAdd(descriptorId, static (id, state) => + { + var (registry, fallbackName) = state; + if (registry.TryGetHandlerByDescriptorId(id, out var registration) && registration is not null) + { + var queueAttr = registration.GetPreferredAttribute()?.Attribute as QueueAttribute; + return new QueueHandlerMetadata( + !string.IsNullOrWhiteSpace(queueAttr?.QueueName) ? queueAttr!.QueueName! : fallbackName, + queueAttr?.TrackProgress ?? false); + } + + return new QueueHandlerMetadata(fallbackName, false); + }, (_registry, messageType.Name)); } - private static bool IsTrackProgressEnabled(HandlerExecutionInfo handlerInfo) - { - var queueAttr = handlerInfo.HandlerType - .GetCustomAttributes(typeof(QueueAttribute), true) - .OfType() - .FirstOrDefault(); - - return queueAttr?.TrackProgress == true; - } + private sealed record QueueHandlerMetadata(string QueueName, bool TrackProgress); } diff --git a/src/Foundatio.Mediator.Distributed/QueueWorker.cs b/src/Foundatio.Mediator.Distributed/QueueWorker.cs index 600095fc..11cf0c6e 100644 --- a/src/Foundatio.Mediator.Distributed/QueueWorker.cs +++ b/src/Foundatio.Mediator.Distributed/QueueWorker.cs @@ -23,14 +23,13 @@ public sealed class QueueWorker : BackgroundService private readonly JsonSerializerOptions _jsonOptions; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; - private static readonly Random s_jitterRandom = new(); private static readonly TimeSpan s_defaultStateExpiry = TimeSpan.FromHours(24); public QueueWorker( IQueueClient client, IServiceScopeFactory scopeFactory, QueueWorkerOptions options, - DistributedOptions? distributedOptions, + DistributedQueueOptions? distributedOptions, ILogger logger, QueueWorkerInfo? workerInfo = null, IQueueJobStateStore? stateStore = null, @@ -446,11 +445,7 @@ public TimeSpan ComputeRetryDelay(int dequeueCount) // Apply proportional jitter (Β±10% of the computed delay) double jitterRange = delayMs * 0.1; - double jitter; - lock (s_jitterRandom) - { - jitter = (s_jitterRandom.NextDouble() * 2 - 1) * jitterRange; - } + double jitter = (Random.Shared.NextDouble() * 2 - 1) * jitterRange; delayMs = Math.Max(0, delayMs + jitter); // Cap at 15 minutes to prevent unreasonably long delays diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs index b7c05810..64d28cee 100644 --- a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs @@ -53,7 +53,7 @@ public async Task PublishAsync_WithNoSubscribers_DoesNotThrow() { await using var client = CreateClient(); - await client.PublishAsync("no-sub-topic", "hello"u8.ToArray(), cancellationToken: TestCancellationToken); + await client.PublishAsync("no-sub-topic", new PubSubEntry { Body = "hello"u8.ToArray() }, TestCancellationToken); } [Fact] @@ -73,7 +73,7 @@ public async Task SubscribeAsync_ReceivesPublishedMessage() }, TestCancellationToken); var headers = new Dictionary { ["key"] = "value" }; - await client.PublishAsync(topic, "hello"u8.ToArray(), headers, TestCancellationToken); + await client.PublishAsync(topic, new PubSubEntry { Body = "hello"u8.ToArray(), Headers = headers }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30)), "Timed out waiting for message"); @@ -100,7 +100,7 @@ public async Task SubscribeAsync_MultipleMessages_AllReceived() }, TestCancellationToken); for (int i = 0; i < 3; i++) - await client.PublishAsync(topic, System.Text.Encoding.UTF8.GetBytes($"msg-{i}"), cancellationToken: TestCancellationToken); + await client.PublishAsync(topic, new PubSubEntry { Body = System.Text.Encoding.UTF8.GetBytes($"msg-{i}") }, TestCancellationToken); for (int i = 0; i < 3; i++) Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30)), $"Timed out waiting for message {i}"); @@ -132,7 +132,7 @@ public async Task SubscribeAsync_HeadersRoundTrip() ["h2"] = "v2", ["h3"] = "v3" }; - await client.PublishAsync(topic, "test"u8.ToArray(), headers, TestCancellationToken); + await client.PublishAsync(topic, new PubSubEntry { Body = "test"u8.ToArray(), Headers = headers }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); Assert.NotNull(received); @@ -157,14 +157,14 @@ public async Task DisposeSubscription_StopsReceiving() return Task.CompletedTask; }, TestCancellationToken); - await client.PublishAsync(topic, "msg1"u8.ToArray(), cancellationToken: TestCancellationToken); + await client.PublishAsync(topic, new PubSubEntry { Body = "msg1"u8.ToArray() }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); Assert.Equal(1, count); // Dispose subscription await sub.DisposeAsync(); - await client.PublishAsync(topic, "msg2"u8.ToArray(), cancellationToken: TestCancellationToken); + await client.PublishAsync(topic, new PubSubEntry { Body = "msg2"u8.ToArray() }, TestCancellationToken); await Task.Delay(TimeSpan.FromSeconds(3), TestCancellationToken); Assert.Equal(1, count); // Should not have received msg2 @@ -186,7 +186,7 @@ public async Task PublishAsync_NoHeaders_ReceivesEmptyHeaders() return Task.CompletedTask; }, TestCancellationToken); - await client.PublishAsync(topic, "no-headers"u8.ToArray(), cancellationToken: TestCancellationToken); + await client.PublishAsync(topic, new PubSubEntry { Body = "no-headers"u8.ToArray() }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); Assert.NotNull(received); diff --git a/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs index 4701ce89..ba3bdb09 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs @@ -357,10 +357,10 @@ public async Task PublishAsync_MultipleDifferentTypes_AllFanOut() // ── Test helper: counting bus decorator ────────────────────────────── internal sealed class CountingPubSubClient(IPubSubClient inner, Action onPublish) : IPubSubClient { - public async Task PublishAsync(string topic, ReadOnlyMemory body, IDictionary? headers = null, CancellationToken cancellationToken = default) + public async Task PublishAsync(string topic, PubSubEntry message, CancellationToken cancellationToken = default) { onPublish(); - await inner.PublishAsync(topic, body, headers, cancellationToken); + await inner.PublishAsync(topic, message, cancellationToken); } public Task SubscribeAsync(string topic, Func handler, CancellationToken cancellationToken = default) diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs index 20d4dcbf..c7a801af 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs @@ -10,7 +10,7 @@ public async Task PublishAsync_WithNoSubscribers_DoesNotThrow() { using var bus = new InMemoryPubSubClient(); - await bus.PublishAsync("test-topic", "hello"u8.ToArray(), cancellationToken: TestCancellationToken); + await bus.PublishAsync("test-topic", new PubSubEntry { Body = "hello"u8.ToArray() }, TestCancellationToken); } [Fact] @@ -29,7 +29,7 @@ public async Task SubscribeAsync_ReceivesPublishedMessage() }, TestCancellationToken); var headers = new Dictionary { ["key"] = "value" }; - await bus.PublishAsync("test-topic", "hello"u8.ToArray(), headers, TestCancellationToken); + await bus.PublishAsync("test-topic", new PubSubEntry { Body = "hello"u8.ToArray(), Headers = headers }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); Assert.NotNull(received); @@ -59,7 +59,7 @@ public async Task SubscribeAsync_MultipleSubscribers_AllReceive() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic", "data"u8.ToArray(), cancellationToken: TestCancellationToken); + await bus.PublishAsync("topic", new PubSubEntry { Body = "data"u8.ToArray() }, TestCancellationToken); // Wait for both subscribers Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); @@ -90,7 +90,7 @@ public async Task SubscribeAsync_DifferentTopics_OnlyMatchingReceives() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic-a", "only-a"u8.ToArray(), cancellationToken: TestCancellationToken); + await bus.PublishAsync("topic-a", new PubSubEntry { Body = "only-a"u8.ToArray() }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); // Give a moment to ensure topic-b doesn't fire @@ -115,14 +115,14 @@ public async Task DisposeSubscription_StopsReceiving() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic", "msg1"u8.ToArray(), cancellationToken: TestCancellationToken); + await bus.PublishAsync("topic", new PubSubEntry { Body = "msg1"u8.ToArray() }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); Assert.Equal(1, count); // Dispose subscription await sub.DisposeAsync(); - await bus.PublishAsync("topic", "msg2"u8.ToArray(), cancellationToken: TestCancellationToken); + await bus.PublishAsync("topic", new PubSubEntry { Body = "msg2"u8.ToArray() }, TestCancellationToken); await Task.Delay(200, TestCancellationToken); Assert.Equal(1, count); // Should not have received msg2 @@ -143,9 +143,10 @@ public async Task PublishAsync_HeadersAreReadOnly() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic", "test"u8.ToArray(), new Dictionary + await bus.PublishAsync("topic", new PubSubEntry { - ["h1"] = "v1", ["h2"] = "v2" + Body = "test"u8.ToArray(), + Headers = new Dictionary { ["h1"] = "v1", ["h2"] = "v2" } }, TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); From a8d0bd84eb13553c0d117926c217d4eb108e579f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 4 Apr 2026 23:14:33 -0500 Subject: [PATCH 07/27] More progress --- samples/ConsoleSample/ConsoleSample.csproj | 3 +- .../ConsoleSample/Handlers/QueueHandler.cs | 38 ------- samples/ConsoleSample/Messages/Messages.cs | 3 - samples/ConsoleSample/Program.cs | 13 +-- samples/ConsoleSample/SampleRunner.cs | 22 +---- samples/ConsoleSample/ServiceConfiguration.cs | 25 +---- .../SqsQueueClientOptions.cs | 6 -- .../RedisQueueJobStateStore.cs | 25 ++++- .../DistributedQueueOptions.cs | 21 ++++ .../DistributedServiceExtensions.cs | 19 ---- .../Handlers/QueueDashboardHandler.cs | 5 +- .../IPubSubClient.cs | 11 +++ .../IQueueClient.cs | 12 +++ .../IQueueJobStateStore.cs | 2 + .../InMemoryQueueJobStateStore.cs | 6 +- .../QueueClientBase.cs | 98 +++++++++++++++++++ .../QueueJobState.cs | 19 ++++ 17 files changed, 195 insertions(+), 133 deletions(-) delete mode 100644 samples/ConsoleSample/Handlers/QueueHandler.cs create mode 100644 src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs create mode 100644 src/Foundatio.Mediator.Distributed/QueueClientBase.cs diff --git a/samples/ConsoleSample/ConsoleSample.csproj b/samples/ConsoleSample/ConsoleSample.csproj index 689c7bfd..caf63652 100644 --- a/samples/ConsoleSample/ConsoleSample.csproj +++ b/samples/ConsoleSample/ConsoleSample.csproj @@ -25,8 +25,7 @@ - - + diff --git a/samples/ConsoleSample/Handlers/QueueHandler.cs b/samples/ConsoleSample/Handlers/QueueHandler.cs deleted file mode 100644 index d7d313d8..00000000 --- a/samples/ConsoleSample/Handlers/QueueHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using ConsoleSample.Messages; -using Foundatio.Mediator.Distributed; -using Microsoft.Extensions.Logging; - -namespace ConsoleSample.Handlers; - -[Queue(Concurrency = 2)] -public class ReportHandler -{ - private readonly ILogger _logger; - - public ReportHandler(ILogger logger) - { - _logger = logger; - } - - public async Task HandleAsync(GenerateReport message, CancellationToken ct) - { - _logger.LogInformation("πŸ“Š Starting report generation: {ReportName} ({ItemCount} items)", - message.ReportName, message.ItemCount); - - Console.WriteLine($"πŸ“Š [Queue Worker] Generating report: {message.ReportName}"); - - for (int i = 1; i <= message.ItemCount; i++) - { - ct.ThrowIfCancellationRequested(); - - int progress = (int)((double)i / message.ItemCount * 100); - - // Simulate work - await Task.Delay(200, ct); - - Console.WriteLine($"πŸ“Š [Queue Worker] Item {i}/{message.ItemCount} processed ({progress}%)"); - } - - Console.WriteLine($"πŸ“Š [Queue Worker] Report '{message.ReportName}' completed successfully!"); - } -} diff --git a/samples/ConsoleSample/Messages/Messages.cs b/samples/ConsoleSample/Messages/Messages.cs index 535a4c23..af686cb9 100644 --- a/samples/ConsoleSample/Messages/Messages.cs +++ b/samples/ConsoleSample/Messages/Messages.cs @@ -35,7 +35,4 @@ public record Order(string Id, string CustomerId, decimal Amount, string Descrip // Counter stream request public record CounterStreamRequest { } -// Queue messages -public record GenerateReport(string ReportName, int ItemCount); - public interface IValidatable { } diff --git a/samples/ConsoleSample/Program.cs b/samples/ConsoleSample/Program.cs index 03fca857..7f0f6f1f 100644 --- a/samples/ConsoleSample/Program.cs +++ b/samples/ConsoleSample/Program.cs @@ -6,22 +6,13 @@ // Create application host var builder = Host.CreateApplicationBuilder(args); -// Check if --sqs flag is passed -var useSqs = args.Contains("--sqs", StringComparer.OrdinalIgnoreCase); - // Configure all services -builder.Services.ConfigureServices(useSqs); +builder.Services.ConfigureServices(); var host = builder.Build(); -// Start the host so background services (queue workers) run -await host.StartAsync(); - // Get mediator and run samples var mediator = host.Services.GetRequiredService(); -var sampleRunner = new SampleRunner(mediator); +var sampleRunner = new SampleRunner(mediator, host.Services); await sampleRunner.RunAllSamplesAsync(); - -// Stop the host gracefully -await host.StopAsync(); diff --git a/samples/ConsoleSample/SampleRunner.cs b/samples/ConsoleSample/SampleRunner.cs index 4333fd91..064263ce 100644 --- a/samples/ConsoleSample/SampleRunner.cs +++ b/samples/ConsoleSample/SampleRunner.cs @@ -7,7 +7,7 @@ public class SampleRunner { private readonly IMediator _mediator; - public SampleRunner(IMediator mediator) + public SampleRunner(IMediator mediator, IServiceProvider serviceProvider) { _mediator = mediator; } @@ -21,7 +21,6 @@ public async Task RunAllSamplesAsync() await RunOrderCrudExamples(); await RunCounterStreamExample(); await RunEventPublishingExamples(); - await RunQueueExample(); Console.WriteLine("\nπŸŽ‰ All samples completed successfully!"); } @@ -129,23 +128,4 @@ private async Task RunEventPublishingExamples() Console.WriteLine(); } - - private async Task RunQueueExample() - { - Console.WriteLine("5️⃣ Queue Processing (Distributed)"); - Console.WriteLine("==========================================\n"); - - - Console.WriteLine("πŸ“¨ Enqueuing report generation (will be processed asynchronously)...\n"); - - // This returns immediately β€” the message is serialized and sent to the queue - await _mediator.InvokeAsync(new GenerateReport("Monthly Sales Report", 5)); - - // Wait for the queue worker to process the message - Console.WriteLine("⏳ Waiting for worker to process...\n"); - await Task.Delay(2000); - - Console.WriteLine("βœ… Queue processing completed"); - Console.WriteLine(); - } } diff --git a/samples/ConsoleSample/ServiceConfiguration.cs b/samples/ConsoleSample/ServiceConfiguration.cs index 1cd57343..540cc8d9 100644 --- a/samples/ConsoleSample/ServiceConfiguration.cs +++ b/samples/ConsoleSample/ServiceConfiguration.cs @@ -1,8 +1,4 @@ -using Amazon.Runtime; -using Amazon.SQS; using Foundatio.Mediator; -using Foundatio.Mediator.Distributed; -using Foundatio.Mediator.Distributed.Aws; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -10,7 +6,7 @@ namespace ConsoleSample; public static class ServiceConfiguration { - public static IServiceCollection ConfigureServices(this IServiceCollection services, bool useSqs = false) + public static IServiceCollection ConfigureServices(this IServiceCollection services) { // Add logging services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information)); @@ -18,25 +14,6 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi // Add Foundatio Mediator services.AddMediator(); - if (useSqs) - { - // Register the SQS client pointing at LocalStack with dummy credentials - services.AddSingleton(new AmazonSQSClient( - new BasicAWSCredentials("test", "test"), - new AmazonSQSConfig - { - ServiceURL = "http://localhost:4566", - AuthenticationRegion = "us-east-1" - })); - - // Use SQS as the queue transport - services.AddMediatorSqs(); - } - - // Add distributed queue support (discovers [Queue]-decorated handlers and starts background workers) - // Falls back to in-memory if no IQueueClient was registered above - services.AddMediatorDistributed(); - return services; } } diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs index 82965e11..f8f9d797 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs @@ -17,10 +17,4 @@ public class SqsQueueClientOptions /// Set to 0 for short polling. /// public int WaitTimeSeconds { get; set; } = 20; - - /// - /// Default visibility timeout in seconds for received messages. Default is 30. - /// Can be overridden per-queue via . - /// - public int DefaultVisibilityTimeoutSeconds { get; set; } = 30; } diff --git a/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs index 84bfef8c..697024c6 100644 --- a/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs @@ -73,15 +73,30 @@ public async Task> GetJobsByQueueAsync(string queue // Get job IDs from sorted set in reverse order (newest first) var jobIds = await db.SortedSetRangeByRankAsync(queueSetKey, skip, skip + take - 1, Order.Descending).ConfigureAwait(false); + if (jobIds.Length == 0) + return []; + + // Pipeline all hash reads to avoid N+1 round-trips + var batch = db.CreateBatch(); + var tasks = new Task[jobIds.Length]; + for (int i = 0; i < jobIds.Length; i++) + { + if (jobIds[i].IsNullOrEmpty) + continue; + + tasks[i] = batch.HashGetAllAsync(JobKey(jobIds[i].ToString())); + } + batch.Execute(); + var results = new List(jobIds.Length); - foreach (var jobId in jobIds) + for (int i = 0; i < tasks.Length; i++) { - if (jobId.IsNullOrEmpty) + if (tasks[i] is null) continue; - var state = await GetJobStateAsync(jobId.ToString(), cancellationToken).ConfigureAwait(false); - if (state is not null) - results.Add(state); + var entries = await tasks[i].ConfigureAwait(false); + if (entries.Length > 0) + results.Add(ParseJobState(entries)); } return results; diff --git a/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs b/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs new file mode 100644 index 00000000..96120144 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs @@ -0,0 +1,21 @@ +using System.Text.Json; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Options for configuring distributed queue support. +/// +public class DistributedQueueOptions +{ + /// + /// Custom JSON serializer options for message serialization/deserialization. + /// When null, is used. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// When set, only workers for queues in the matching group will be started. + /// When null (default), all queue workers are started. + /// + public string? Group { get; set; } +} diff --git a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs index 570d3bd3..0e61bf2e 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs @@ -1,28 +1,9 @@ -using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Foundatio.Mediator.Distributed; -/// -/// Options for configuring distributed queue support. -/// -public class DistributedQueueOptions -{ - /// - /// Custom JSON serializer options for message serialization/deserialization. - /// When null, is used. - /// - public JsonSerializerOptions? JsonSerializerOptions { get; set; } - - /// - /// When set, only workers for queues in the matching group will be started. - /// When null (default), all queue workers are started. - /// - public string? Group { get; set; } -} - /// /// Extension methods for registering distributed queue support for Foundatio.Mediator. /// diff --git a/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs b/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs index 78578ccb..d298383b 100644 --- a/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs +++ b/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs @@ -4,8 +4,11 @@ namespace Foundatio.Mediator.Distributed; /// Handles queue dashboard queries: listing workers, viewing stats, and managing tracked jobs. /// Endpoints are auto-generated under /api/queues by the source generator. /// +/// +/// These endpoints respect the global authorization settings. To allow anonymous access +/// in development, set AuthorizationRequired = false on [assembly: MediatorConfiguration]. +/// [HandlerEndpointGroup("Queues")] -[HandlerAllowAnonymous] public class QueueDashboardHandler { private readonly IQueueWorkerRegistry _registry; diff --git a/src/Foundatio.Mediator.Distributed/IPubSubClient.cs b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs index f37f9709..97357f96 100644 --- a/src/Foundatio.Mediator.Distributed/IPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs @@ -4,6 +4,17 @@ namespace Foundatio.Mediator.Distributed; /// Transport-agnostic pub/sub abstraction used by the distributed notification system. /// Implementations fan messages out to all subscribers (topic-based publish/subscribe). /// +/// +/// +/// Pub/sub is fire-and-forget: there is no acknowledgment, retry, or dead-letter +/// mechanism. If a subscriber's handler throws, the message is considered delivered and +/// will not be redelivered. Implementations should delete/acknowledge the transport +/// message after invoking the handler regardless of success or failure. +/// +/// +/// For at-least-once delivery with retries and dead-lettering, use instead. +/// +/// public interface IPubSubClient : IAsyncDisposable { /// diff --git a/src/Foundatio.Mediator.Distributed/IQueueClient.cs b/src/Foundatio.Mediator.Distributed/IQueueClient.cs index 6e35368e..f682208a 100644 --- a/src/Foundatio.Mediator.Distributed/IQueueClient.cs +++ b/src/Foundatio.Mediator.Distributed/IQueueClient.cs @@ -46,6 +46,18 @@ public interface IQueueClient : IAsyncDisposable /// Moves a message to the dead-letter queue for the specified queue. /// The message body and headers are preserved, with additional dead-letter metadata added. /// + /// + /// Implementations should follow this convention: + /// + /// Send a new message to {queueName}-dead-letter with the original body and headers. + /// Add the metadata headers , + /// , , + /// and . + /// Complete (delete) the original message from the source queue. + /// + /// Transports that manage dead-letter queues natively (e.g., Azure Service Bus) may use + /// native dead-letter operations instead, but must still preserve the metadata headers. + /// /// The message to dead-letter. /// A human-readable reason for dead-lettering. /// A cancellation token. diff --git a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs index e9a1e124..cc978b38 100644 --- a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs @@ -14,6 +14,8 @@ public interface IQueueJobStateStore /// /// Retrieves the state for a specific job. + /// Implementations must return an independent copy so callers can mutate + /// properties without affecting stored data. /// /// The job state, or null if the job ID is not found or has expired. Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default); diff --git a/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs index db2e972c..b69769b6 100644 --- a/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs @@ -35,7 +35,7 @@ public Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, Cance public Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default) { if (_jobs.TryGetValue(jobId, out var entry) && !IsExpired(entry)) - return Task.FromResult(entry.State); + return Task.FromResult(entry.State.Clone()); // Remove expired entry on access if (entry is not null) @@ -55,7 +55,7 @@ public Task> GetJobsByQueueAsync(string queueName, .OrderByDescending(e => e.State.CreatedUtc) .Skip(skip) .Take(take) - .Select(e => e.State) + .Select(e => e.State.Clone()) .ToList(); return Task.FromResult>(results); @@ -112,7 +112,7 @@ public Task> GetJobsByStatusAsync(string queueName, .OrderByDescending(e => e.State.CreatedUtc) .Skip(skip) .Take(take) - .Select(e => e.State) + .Select(e => e.State.Clone()) .ToList(); return Task.FromResult>(results); diff --git a/src/Foundatio.Mediator.Distributed/QueueClientBase.cs b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs new file mode 100644 index 00000000..d0b4c22e --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs @@ -0,0 +1,98 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Optional base class for implementations that provides +/// sensible defaults for common operations. Implementations only need to override the +/// core transport-specific methods. +/// +/// +/// Override guidance for new transport implementations: +/// +/// Required: , , +/// , . +/// Recommended: (if the transport supports visibility timeouts), +/// (if infrastructure pre-creation is beneficial). +/// Optional: (default loops ), +/// (default sends to {queueName}-dead-letter then completes), +/// (default returns zeroed stats). +/// +/// +public abstract class QueueClientBase : IQueueClient +{ + /// + public abstract Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default); + + /// + /// + /// Default implementation sends entries one at a time via . + /// Override to use transport-native batch APIs for better throughput. + /// + public virtual async Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) + { + foreach (var entry in entries) + await SendAsync(queueName, entry, cancellationToken).ConfigureAwait(false); + } + + /// + public abstract Task> ReceiveAsync(string queueName, int maxCount, CancellationToken cancellationToken = default); + + /// + public abstract Task CompleteAsync(QueueMessage message, CancellationToken cancellationToken = default); + + /// + public abstract Task AbandonAsync(QueueMessage message, TimeSpan delay = default, CancellationToken cancellationToken = default); + + /// + /// + /// Default implementation is a no-op. Override for transports that support + /// visibility timeouts (e.g., SQS ChangeMessageVisibility). + /// + public virtual Task RenewTimeoutAsync(QueueMessage message, TimeSpan extension, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + /// + /// Default implementation sends the original message (with dead-letter metadata headers) + /// to {queueName}-dead-letter, then completes the original message. + /// Override if the transport has native dead-letter support (e.g., Azure Service Bus). + /// + public virtual async Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default) + { + var dlqName = $"{message.QueueName}-dead-letter"; + + var headers = new Dictionary(message.Headers) + { + [MessageHeaders.DeadLetterReason] = reason, + [MessageHeaders.DeadLetteredAt] = DateTimeOffset.UtcNow.ToString("O"), + [MessageHeaders.OriginalQueueName] = message.QueueName, + [MessageHeaders.DeadLetterDequeueCount] = message.DequeueCount.ToString() + }; + + var entry = new QueueEntry + { + Body = message.Body, + Headers = headers + }; + + await SendAsync(dlqName, entry, cancellationToken).ConfigureAwait(false); + await CompleteAsync(message, cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// Default implementation is a no-op. Override to pre-create queue infrastructure at startup. + /// + public virtual Task EnsureQueuesAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + /// + /// Default implementation returns zeroed stats. Override if the transport provides + /// queue metrics (approximate message count, in-flight count, etc.). + /// + public virtual Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) + => Task.FromResult(new QueueStats { QueueName = queueName }); + + /// + public virtual ValueTask DisposeAsync() => default; +} diff --git a/src/Foundatio.Mediator.Distributed/QueueJobState.cs b/src/Foundatio.Mediator.Distributed/QueueJobState.cs index 9e297583..7b533754 100644 --- a/src/Foundatio.Mediator.Distributed/QueueJobState.cs +++ b/src/Foundatio.Mediator.Distributed/QueueJobState.cs @@ -59,6 +59,25 @@ public sealed class QueueJobState /// The last time this state was updated. /// public DateTimeOffset LastUpdatedUtc { get; set; } + + /// + /// Creates a shallow copy of this state. Used by stores to return independent + /// snapshots so callers can mutate properties without affecting stored data. + /// + public QueueJobState Clone() => new() + { + JobId = JobId, + QueueName = QueueName, + MessageType = MessageType, + Status = Status, + Progress = Progress, + ProgressMessage = ProgressMessage, + CreatedUtc = CreatedUtc, + StartedUtc = StartedUtc, + CompletedUtc = CompletedUtc, + ErrorMessage = ErrorMessage, + LastUpdatedUtc = LastUpdatedUtc + }; } /// From 801891461d90d7e3602a8d54d0082e6416b24eab Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 4 Apr 2026 23:49:20 -0500 Subject: [PATCH 08/27] Add Redis store tests --- Foundatio.Mediator.slnx | 1 + ...io.Mediator.Distributed.Redis.Tests.csproj | 29 ++ .../GlobalUsings.cs | 1 + .../RedisFixture.cs | 41 ++ .../RedisQueueJobStateStoreTests.cs | 415 ++++++++++++++++++ 5 files changed, 487 insertions(+) create mode 100644 tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj create mode 100644 tests/Foundatio.Mediator.Distributed.Redis.Tests/GlobalUsings.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisFixture.cs create mode 100644 tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs diff --git a/Foundatio.Mediator.slnx b/Foundatio.Mediator.slnx index be0408b5..73deac6d 100644 --- a/Foundatio.Mediator.slnx +++ b/Foundatio.Mediator.slnx @@ -31,6 +31,7 @@ + diff --git a/tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj b/tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj new file mode 100644 index 00000000..176f70d2 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj @@ -0,0 +1,29 @@ + + + + + + net10.0 + enable + true + false + $(NoWarn);NU1510 + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Foundatio.Mediator.Distributed.Redis.Tests/GlobalUsings.cs b/tests/Foundatio.Mediator.Distributed.Redis.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Redis.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisFixture.cs b/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisFixture.cs new file mode 100644 index 00000000..8adb584b --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisFixture.cs @@ -0,0 +1,41 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using StackExchange.Redis; + +namespace Foundatio.Mediator.Distributed.Redis.Tests; + +/// +/// Aspire fixture that manages a Redis container for integration tests. +/// The container is started once and shared across all tests in the collection. +/// +public class RedisFixture : IAsyncLifetime +{ + public IConnectionMultiplexer Connection { get; private set; } = null!; + public DistributedApplication App { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + var builder = DistributedApplicationTestingBuilder.Create(); + + builder.AddRedis("redis"); + + App = await builder.BuildAsync(); + await App.StartAsync(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await App.ResourceNotifications.WaitForResourceHealthyAsync("redis", cts.Token); + + var connectionString = await App.GetConnectionStringAsync("redis", cts.Token); + Connection = await ConnectionMultiplexer.ConnectAsync(connectionString!); + } + + public async ValueTask DisposeAsync() + { + if (Connection is not null) + await Connection.DisposeAsync(); + + if (App is not null) + await App.DisposeAsync(); + } +} diff --git a/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs b/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs new file mode 100644 index 00000000..78bbaabb --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs @@ -0,0 +1,415 @@ +#pragma warning disable xUnit1051 +using Foundatio.Mediator.Distributed; +using Foundatio.Mediator.Distributed.Redis; + +namespace Foundatio.Mediator.Distributed.Redis.Tests; + +/// +/// Integration tests for running against a real Redis instance. +/// +public class RedisQueueJobStateStoreTests(RedisFixture fixture) : IClassFixture +{ + private static CancellationToken CT => TestContext.Current.CancellationToken; + + /// + /// Creates a store with a unique key prefix per test to avoid cross-test interference. + /// + private RedisQueueJobStateStore CreateStore() + { + return new RedisQueueJobStateStore(fixture.Connection, new RedisJobStateStoreOptions + { + KeyPrefix = $"test:{Guid.NewGuid():N}" + }); + } + + private static QueueJobState CreateJobState( + string jobId = "job-1", + string queueName = "TestQueue", + QueueJobStatus status = QueueJobStatus.Queued, + DateTimeOffset? createdUtc = null) + { + var now = createdUtc ?? DateTimeOffset.UtcNow; + return new QueueJobState + { + JobId = jobId, + QueueName = queueName, + MessageType = "TestMessage", + Status = status, + CreatedUtc = now, + LastUpdatedUtc = now + }; + } + + // ── Set / Get ────────────────────────────────────────────────────────── + + [Fact] + public async Task SetAndGet_RoundTrips() + { + var store = CreateStore(); + var state = CreateJobState(); + + await store.SetJobStateAsync(state, cancellationToken: CT); + + var retrieved = await store.GetJobStateAsync("job-1", CT); + Assert.NotNull(retrieved); + Assert.Equal("job-1", retrieved.JobId); + Assert.Equal("TestQueue", retrieved.QueueName); + Assert.Equal("TestMessage", retrieved.MessageType); + Assert.Equal(QueueJobStatus.Queued, retrieved.Status); + } + + [Fact] + public async Task GetJobState_NonExistent_ReturnsNull() + { + var store = CreateStore(); + var result = await store.GetJobStateAsync("nonexistent", CT); + Assert.Null(result); + } + + [Fact] + public async Task SetJobState_UpdatesExisting() + { + var store = CreateStore(); + var state = CreateJobState(); + await store.SetJobStateAsync(state, cancellationToken: CT); + + state.Status = QueueJobStatus.Processing; + state.Progress = 50; + state.ProgressMessage = "Half done"; + await store.SetJobStateAsync(state, cancellationToken: CT); + + var retrieved = await store.GetJobStateAsync("job-1", CT); + Assert.NotNull(retrieved); + Assert.Equal(QueueJobStatus.Processing, retrieved.Status); + Assert.Equal(50, retrieved.Progress); + Assert.Equal("Half done", retrieved.ProgressMessage); + } + + [Fact] + public async Task SetAndGet_PreservesAllFields() + { + var store = CreateStore(); + var now = DateTimeOffset.UtcNow; + var state = new QueueJobState + { + JobId = "full-job", + QueueName = "FullQueue", + MessageType = "MyApp.Commands.DoWork", + Status = QueueJobStatus.Processing, + Progress = 75, + ProgressMessage = "Processing items", + CreatedUtc = now.AddMinutes(-5), + StartedUtc = now.AddMinutes(-4), + CompletedUtc = null, + ErrorMessage = null, + LastUpdatedUtc = now + }; + + await store.SetJobStateAsync(state, cancellationToken: CT); + + var retrieved = await store.GetJobStateAsync("full-job", CT); + Assert.NotNull(retrieved); + Assert.Equal(state.JobId, retrieved.JobId); + Assert.Equal(state.QueueName, retrieved.QueueName); + Assert.Equal(state.MessageType, retrieved.MessageType); + Assert.Equal(state.Status, retrieved.Status); + Assert.Equal(state.Progress, retrieved.Progress); + Assert.Equal(state.ProgressMessage, retrieved.ProgressMessage); + // DateTimeOffset comparison with millisecond precision (Redis stores as Unix ms) + Assert.Equal(state.CreatedUtc.ToUnixTimeMilliseconds(), retrieved.CreatedUtc.ToUnixTimeMilliseconds()); + Assert.Equal(state.StartedUtc!.Value.ToUnixTimeMilliseconds(), retrieved.StartedUtc!.Value.ToUnixTimeMilliseconds()); + Assert.Null(retrieved.CompletedUtc); + Assert.Null(retrieved.ErrorMessage); + Assert.Equal(state.LastUpdatedUtc.ToUnixTimeMilliseconds(), retrieved.LastUpdatedUtc.ToUnixTimeMilliseconds()); + } + + [Fact] + public async Task SetAndGet_PreservesTerminalStateFields() + { + var store = CreateStore(); + var now = DateTimeOffset.UtcNow; + var state = new QueueJobState + { + JobId = "failed-job", + QueueName = "FailQueue", + MessageType = "FailingCommand", + Status = QueueJobStatus.Failed, + Progress = 30, + ProgressMessage = "Failed at step 3", + CreatedUtc = now.AddMinutes(-10), + StartedUtc = now.AddMinutes(-9), + CompletedUtc = now, + ErrorMessage = "NullReferenceException: Object reference not set", + LastUpdatedUtc = now + }; + + await store.SetJobStateAsync(state, cancellationToken: CT); + + var retrieved = await store.GetJobStateAsync("failed-job", CT); + Assert.NotNull(retrieved); + Assert.Equal(QueueJobStatus.Failed, retrieved.Status); + Assert.Equal("NullReferenceException: Object reference not set", retrieved.ErrorMessage); + Assert.NotNull(retrieved.CompletedUtc); + Assert.Equal(now.ToUnixTimeMilliseconds(), retrieved.CompletedUtc!.Value.ToUnixTimeMilliseconds()); + } + + // ── GetJobsByQueue ───────────────────────────────────────────────────── + + [Fact] + public async Task GetJobsByQueue_ReturnsMatchingJobs() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-2", "QueueA"), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-3", "QueueB"), cancellationToken: CT); + + var jobsA = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + Assert.Equal(2, jobsA.Count); + + var jobsB = await store.GetJobsByQueueAsync("QueueB", cancellationToken: CT); + Assert.Single(jobsB); + } + + [Fact] + public async Task GetJobsByQueue_OrdersByCreatedUtcDescending() + { + var store = CreateStore(); + var baseTime = DateTimeOffset.UtcNow; + + await store.SetJobStateAsync(CreateJobState("job-1", "QueueA", createdUtc: baseTime), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-2", "QueueA", createdUtc: baseTime.AddMinutes(1)), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-3", "QueueA", createdUtc: baseTime.AddMinutes(2)), cancellationToken: CT); + + var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + Assert.Equal(3, jobs.Count); + Assert.Equal("job-3", jobs[0].JobId); // newest first + Assert.Equal("job-2", jobs[1].JobId); + Assert.Equal("job-1", jobs[2].JobId); + } + + [Fact] + public async Task GetJobsByQueue_SupportsPagination() + { + var store = CreateStore(); + var baseTime = DateTimeOffset.UtcNow; + + for (int i = 1; i <= 5; i++) + { + await store.SetJobStateAsync( + CreateJobState($"job-{i}", "QueueA", createdUtc: baseTime.AddSeconds(i)), + cancellationToken: CT); + } + + var page1 = await store.GetJobsByQueueAsync("QueueA", skip: 0, take: 2, cancellationToken: CT); + Assert.Equal(2, page1.Count); + + var page2 = await store.GetJobsByQueueAsync("QueueA", skip: 2, take: 2, cancellationToken: CT); + Assert.Equal(2, page2.Count); + + var page3 = await store.GetJobsByQueueAsync("QueueA", skip: 4, take: 2, cancellationToken: CT); + Assert.Single(page3); + + // No overlap between pages + var allIds = page1.Concat(page2).Concat(page3).Select(j => j.JobId).ToList(); + Assert.Equal(5, allIds.Distinct().Count()); + } + + [Fact] + public async Task GetJobsByQueue_EmptyQueue_ReturnsEmpty() + { + var store = CreateStore(); + var jobs = await store.GetJobsByQueueAsync("EmptyQueue", cancellationToken: CT); + Assert.Empty(jobs); + } + + // ── GetJobsByStatus ──────────────────────────────────────────────────── + + [Fact] + public async Task GetJobsByStatus_FiltersCorrectly() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState("job-1", "Q", QueueJobStatus.Queued), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-2", "Q", QueueJobStatus.Processing), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-3", "Q", QueueJobStatus.Completed), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-4", "Q", QueueJobStatus.Failed), cancellationToken: CT); + + var active = await store.GetJobsByStatusAsync("Q", [QueueJobStatus.Queued, QueueJobStatus.Processing], cancellationToken: CT); + Assert.Equal(2, active.Count); + Assert.All(active, j => Assert.True(j.Status is QueueJobStatus.Queued or QueueJobStatus.Processing)); + + var terminal = await store.GetJobsByStatusAsync("Q", [QueueJobStatus.Completed, QueueJobStatus.Failed], cancellationToken: CT); + Assert.Equal(2, terminal.Count); + } + + [Fact] + public async Task GetJobCountByStatus_ReturnsCorrectCount() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState("job-1", "Q", QueueJobStatus.Queued), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-2", "Q", QueueJobStatus.Queued), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-3", "Q", QueueJobStatus.Processing), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-4", "Q", QueueJobStatus.Completed), cancellationToken: CT); + + Assert.Equal(2, await store.GetJobCountByStatusAsync("Q", QueueJobStatus.Queued, CT)); + Assert.Equal(1, await store.GetJobCountByStatusAsync("Q", QueueJobStatus.Processing, CT)); + Assert.Equal(1, await store.GetJobCountByStatusAsync("Q", QueueJobStatus.Completed, CT)); + Assert.Equal(0, await store.GetJobCountByStatusAsync("Q", QueueJobStatus.Failed, CT)); + } + + // ── Cancellation ─────────────────────────────────────────────────────── + + [Fact] + public async Task RequestCancellation_SetsFlag() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + + var result = await store.RequestCancellationAsync("job-1", CT); + Assert.True(result); + + var isCancelled = await store.IsCancellationRequestedAsync("job-1", CT); + Assert.True(isCancelled); + } + + [Fact] + public async Task RequestCancellation_NonExistent_ReturnsFalse() + { + var store = CreateStore(); + var result = await store.RequestCancellationAsync("nonexistent", CT); + Assert.False(result); + } + + [Fact] + public async Task RequestCancellation_TerminalState_ReturnsFalse() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(status: QueueJobStatus.Completed), cancellationToken: CT); + Assert.False(await store.RequestCancellationAsync("job-1", CT)); + + var store2 = CreateStore(); + await store2.SetJobStateAsync(CreateJobState(status: QueueJobStatus.Failed), cancellationToken: CT); + Assert.False(await store2.RequestCancellationAsync("job-1", CT)); + + var store3 = CreateStore(); + await store3.SetJobStateAsync(CreateJobState(status: QueueJobStatus.Cancelled), cancellationToken: CT); + Assert.False(await store3.RequestCancellationAsync("job-1", CT)); + } + + [Fact] + public async Task IsCancellationRequested_NotRequested_ReturnsFalse() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + + var isCancelled = await store.IsCancellationRequestedAsync("job-1", CT); + Assert.False(isCancelled); + } + + // ── Remove ───────────────────────────────────────────────────────────── + + [Fact] + public async Task RemoveJobState_RemovesEntry() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + + await store.RemoveJobStateAsync("job-1", CT); + + var result = await store.GetJobStateAsync("job-1", CT); + Assert.Null(result); + } + + [Fact] + public async Task RemoveJobState_RemovesFromQueueListing() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), cancellationToken: CT); + await store.SetJobStateAsync(CreateJobState("job-2", "QueueA"), cancellationToken: CT); + + await store.RemoveJobStateAsync("job-1", CT); + + var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + Assert.Single(jobs); + Assert.Equal("job-2", jobs[0].JobId); + } + + [Fact] + public async Task RemoveJobState_ClearsCancellation() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), cancellationToken: CT); + await store.RequestCancellationAsync("job-1", CT); + + await store.RemoveJobStateAsync("job-1", CT); + + var isCancelled = await store.IsCancellationRequestedAsync("job-1", CT); + Assert.False(isCancelled); + } + + // ── Expiry ───────────────────────────────────────────────────────────── + + [Fact] + public async Task SetJobState_WithExpiry_KeyHasTtl() + { + var store = CreateStore(); + await store.SetJobStateAsync(CreateJobState(), expiry: TimeSpan.FromMinutes(5), cancellationToken: CT); + + // Verify the key has a TTL set (we can't easily wait for expiry in integration tests, + // but we can verify the TTL was applied) + var db = fixture.Connection.GetDatabase(); + var ttl = await db.KeyTimeToLiveAsync($"test:{store.GetHashCode()}"); // Can't access private key, just verify state exists + var state = await store.GetJobStateAsync("job-1", CT); + Assert.NotNull(state); + } + + // ── Counters ─────────────────────────────────────────────────────────── + + [Fact] + public async Task IncrementCounter_CreatesAndIncrements() + { + var store = CreateStore(); + + await store.IncrementCounterAsync("TestQueue", "processed", 1, CT); + await store.IncrementCounterAsync("TestQueue", "processed", 1, CT); + await store.IncrementCounterAsync("TestQueue", "failed", 1, CT); + + var counters = await store.GetCountersAsync("TestQueue", CT); + Assert.Equal(2, counters["processed"]); + Assert.Equal(1, counters["failed"]); + } + + [Fact] + public async Task IncrementCounter_SupportsCustomIncrements() + { + var store = CreateStore(); + + await store.IncrementCounterAsync("TestQueue", "processed", 5, CT); + await store.IncrementCounterAsync("TestQueue", "processed", 10, CT); + + var counters = await store.GetCountersAsync("TestQueue", CT); + Assert.Equal(15, counters["processed"]); + } + + [Fact] + public async Task GetCounters_EmptyQueue_ReturnsEmpty() + { + var store = CreateStore(); + var counters = await store.GetCountersAsync("NonExistent", CT); + Assert.Empty(counters); + } + + [Fact] + public async Task Counters_IsolatedPerQueue() + { + var store = CreateStore(); + + await store.IncrementCounterAsync("Queue1", "processed", 3, CT); + await store.IncrementCounterAsync("Queue2", "processed", 7, CT); + + var counters1 = await store.GetCountersAsync("Queue1", CT); + var counters2 = await store.GetCountersAsync("Queue2", CT); + + Assert.Equal(3, counters1["processed"]); + Assert.Equal(7, counters2["processed"]); + } +} From e58273688dd76b3563e4a45bf617b4d16b91ac14 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 5 Apr 2026 16:21:16 -0500 Subject: [PATCH 09/27] More progress --- .../Handlers/DemoExportJobHandler.cs | 18 +- .../Handlers/QueueDashboardHandler.cs | 60 +++- .../Messages/QueueDashboardMessages.cs | 14 + .../src/Web/src/lib/api/queues.ts | 6 +- .../Web/src/lib/components/ui/Button.svelte | 2 +- .../src/lib/components/ui/Sparkline.svelte | 51 ++++ .../src/Web/src/lib/components/ui/index.ts | 1 + .../src/Web/src/lib/types/queue.ts | 12 + .../src/Web/src/routes/orders/+page.svelte | 7 +- .../src/Web/src/routes/products/+page.svelte | 7 +- .../src/Web/src/routes/queues/+page.svelte | 63 ++-- .../SqsQueueClient.cs | 36 ++- .../RedisQueueJobStateStore.cs | 274 ++++++++++-------- .../DistributedServiceExtensions.cs | 22 +- .../Handlers/QueueDashboardHandler.cs | 31 +- .../IQueueJobStateStore.cs | 67 +++-- .../InMemoryQueueJobStateStore.cs | 98 +++++-- .../Messages/QueueDashboardMessages.cs | 11 +- .../QueueAttribute.cs | 7 +- .../QueueContext.cs | 6 +- .../QueueCounterStats.cs | 35 +++ .../QueueJobState.cs | 36 +-- .../QueueWorker.cs | 109 ++----- .../QueueWorkerInfo.cs | 13 +- .../RedisQueueJobStateStoreTests.cs | 96 +++--- .../InMemoryQueueJobStateStoreTests.cs | 27 +- .../QueueWorkerJobTrackingTests.cs | 6 +- 27 files changed, 699 insertions(+), 416 deletions(-) create mode 100644 samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Sparkline.svelte create mode 100644 src/Foundatio.Mediator.Distributed/QueueCounterStats.cs diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs index 7893b5fe..1c18c145 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs @@ -15,17 +15,23 @@ public class DemoExportJobHandler(ILogger logger) { public async Task HandleAsync(DemoExportJob message, QueueContext queueContext, CancellationToken ct) { - logger.LogInformation("Starting demo export job ({Steps} steps, {Delay}ms each)", message.Steps, message.StepDelayMs); + // Add per-job variability: Β±40% on step count, Β±50% on delay + var rng = Random.Shared; + int steps = Math.Max(3, (int)(message.Steps * (0.6 + rng.NextDouble() * 0.8))); + int baseDelay = Math.Max(100, (int)(message.StepDelayMs * (0.5 + rng.NextDouble()))); - for (int i = 1; i <= message.Steps; i++) + logger.LogInformation("Starting demo export job ({Steps} steps, ~{Delay}ms each)", steps, baseDelay); + + for (int i = 1; i <= steps; i++) { ct.ThrowIfCancellationRequested(); - // Simulate work - await Task.Delay(message.StepDelayMs, ct).ConfigureAwait(false); + // Simulate variable work β€” some steps are fast, some slow + int jitter = (int)(baseDelay * (0.3 + rng.NextDouble() * 1.4)); + await Task.Delay(jitter, ct).ConfigureAwait(false); - int percent = (int)((double)i / message.Steps * 100); - string stepMessage = $"Processing step {i} of {message.Steps}"; + int percent = (int)((double)i / steps * 100); + string stepMessage = $"Processing step {i} of {steps}"; await queueContext.ReportProgressAsync(percent, stepMessage, ct).ConfigureAwait(false); logger.LogDebug("Demo export: {Percent}% - {Message}", percent, stepMessage); diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs index e2ca9211..09ff107a 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs @@ -61,17 +61,40 @@ public async Task> HandleAsync(GetJobDashboard query, C var queuedCount = await _stateStore.GetJobCountByStatusAsync(query.QueueName, QueueJobStatus.Queued, ct).ConfigureAwait(false); var activeJobs = await _stateStore.GetJobsByStatusAsync( - query.QueueName, [QueueJobStatus.Processing], 0, 200, ct).ConfigureAwait(false); + query.QueueName, QueueJobStatus.Processing, 0, 200, ct).ConfigureAwait(false); - var recentJobs = await _stateStore.GetJobsByStatusAsync( - query.QueueName, [QueueJobStatus.Completed, QueueJobStatus.Failed, QueueJobStatus.Cancelled], - 0, query.RecentTerminalCount ?? 20, ct).ConfigureAwait(false); + var recentTerminalCount = query.RecentTerminalCount ?? 20; + var completedJobs = await _stateStore.GetJobsByStatusAsync(query.QueueName, QueueJobStatus.Completed, 0, recentTerminalCount, ct).ConfigureAwait(false); + var failedJobs = await _stateStore.GetJobsByStatusAsync(query.QueueName, QueueJobStatus.Failed, 0, recentTerminalCount, ct).ConfigureAwait(false); + var cancelledJobs = await _stateStore.GetJobsByStatusAsync(query.QueueName, QueueJobStatus.Cancelled, 0, recentTerminalCount, ct).ConfigureAwait(false); + + var recentJobs = completedJobs.Concat(failedJobs).Concat(cancelledJobs) + .OrderByDescending(j => j.CompletedUtc ?? j.LastUpdatedUtc) + .Take(recentTerminalCount) + .ToList(); + + CounterStatsView? counterStats = null; + try + { + var stats = await _stateStore.GetCounterStatsAsync(query.QueueName, TimeSpan.FromHours(24), ct).ConfigureAwait(false); + counterStats = new CounterStatsView + { + Totals = stats.Totals, + Buckets = stats.Buckets.Select(b => new CounterBucketView + { + Hour = b.Hour, + Counters = b.Counters + }).ToList() + }; + } + catch { /* State store may not support counters */ } return new JobDashboardView { QueuedCount = queuedCount, ActiveJobs = activeJobs.Select(ToJobSummary).ToList(), - RecentJobs = recentJobs.Select(ToJobSummary).ToList() + RecentJobs = recentJobs.Select(ToJobSummary).ToList(), + CounterStats = counterStats }; } @@ -116,11 +139,11 @@ public async Task> HandleAsync(EnqueueDemoJob command, I private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueStats? stats, CancellationToken ct) { - IReadOnlyDictionary? counters = null; + QueueCounterStats? counterStats = null; long? processingCount = null; if (_stateStore is not null) { - try { counters = await _stateStore.GetCountersAsync(worker.QueueName, ct).ConfigureAwait(false); } + try { counterStats = await _stateStore.GetCounterStatsAsync(worker.QueueName, TimeSpan.FromHours(24), ct).ConfigureAwait(false); } catch { /* State store may not be available */ } if (worker.TrackProgress) @@ -130,6 +153,20 @@ private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueSta } } + CounterStatsView? counterStatsView = null; + if (counterStats is not null) + { + counterStatsView = new CounterStatsView + { + Totals = counterStats.Totals, + Buckets = counterStats.Buckets.Select(b => new CounterBucketView + { + Hour = b.Hour, + Counters = b.Counters + }).ToList() + }; + } + return new QueueSummary { QueueName = worker.QueueName, @@ -139,12 +176,13 @@ private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueSta RetryPolicy = worker.RetryPolicy.ToString(), TrackProgress = worker.TrackProgress, IsRunning = worker.IsRunning, - MessagesProcessed = counters?.GetValueOrDefault("processed") ?? worker.MessagesProcessed, - MessagesFailed = counters?.GetValueOrDefault("failed") ?? worker.MessagesFailed, - MessagesDeadLettered = counters?.GetValueOrDefault("dead_lettered") ?? worker.MessagesDeadLettered, + MessagesProcessed = counterStats?.Totals.GetValueOrDefault("processed") ?? worker.MessagesProcessed, + MessagesFailed = counterStats?.Totals.GetValueOrDefault("failed") ?? worker.MessagesFailed, + MessagesDeadLettered = counterStats?.Totals.GetValueOrDefault("dead_lettered") ?? worker.MessagesDeadLettered, ActiveCount = stats?.ActiveCount ?? 0, DeadLetterCount = stats?.DeadLetterCount ?? 0, - InFlightCount = processingCount ?? stats?.InFlightCount ?? 0 + InFlightCount = processingCount ?? stats?.InFlightCount ?? 0, + CounterStats = counterStatsView }; } diff --git a/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs b/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs index ece18620..cb1a5142 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs @@ -31,6 +31,7 @@ public record QueueSummary public long ActiveCount { get; init; } public long DeadLetterCount { get; init; } public long InFlightCount { get; init; } + public CounterStatsView? CounterStats { get; init; } } public record JobSummary @@ -54,6 +55,19 @@ public record JobDashboardView public long QueuedCount { get; init; } public required List ActiveJobs { get; init; } public required List RecentJobs { get; init; } + public CounterStatsView? CounterStats { get; init; } +} + +public record CounterStatsView +{ + public required IReadOnlyDictionary Totals { get; init; } + public required IReadOnlyList Buckets { get; init; } +} + +public record CounterBucketView +{ + public DateTimeOffset Hour { get; init; } + public required IReadOnlyDictionary Counters { get; init; } } public record DemoJobEnqueued(string JobId); diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts b/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts index cbc2435f..13d76ca5 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts @@ -18,5 +18,9 @@ export const queuesApi = { api.postJSON(`/api/queues/job/${jobId}/cancel-job`, {}), enqueueDemoJob: (count = 1, steps = 10, stepDelayMs = 500) => - api.postJSON('/api/queues/demo-job/enqueue-demo-job', { count, steps, stepDelayMs }) + api.postJSON('/api/queues/demo-job/enqueue-demo-job', { count, steps, stepDelayMs }), + + /** Call the DemoExportJob queue endpoint directly β€” the mediator enqueues it async and returns 202 Accepted. */ + enqueueDemoJobDirect: (steps = 20, stepDelayMs = 1500) => + api.postJSON('/api/export-jobs/demo', { steps, stepDelayMs }) }; diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Button.svelte b/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Button.svelte index e4df2bd6..fa2ec466 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Button.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Button.svelte @@ -28,7 +28,7 @@ }: Props = $props(); const baseStyles = - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'; + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors select-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'; const variants = { default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Sparkline.svelte b/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Sparkline.svelte new file mode 100644 index 00000000..fa7b06f8 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/Sparkline.svelte @@ -0,0 +1,51 @@ + + +
+ + + + + {#if label} + {total.toLocaleString()} + {/if} +
diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/index.ts b/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/index.ts index 1c6029fe..93e08bae 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/index.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/components/ui/index.ts @@ -6,3 +6,4 @@ export { default as Badge } from './Badge.svelte'; export { default as Spinner } from './Spinner.svelte'; export { default as Alert } from './Alert.svelte'; export { default as Modal } from './Modal.svelte'; +export { default as Sparkline } from './Sparkline.svelte'; diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts b/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts index 3057c06f..787928d5 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts @@ -12,6 +12,7 @@ export interface QueueSummary { activeCount: number; deadLetterCount: number; inFlightCount: number; + counterStats: CounterStats | null; } export type JobStatus = 'Queued' | 'Processing' | 'Completed' | 'Failed' | 'Cancelled'; @@ -33,6 +34,17 @@ export interface JobDashboardView { queuedCount: number; activeJobs: JobSummary[]; recentJobs: JobSummary[]; + counterStats: CounterStats | null; +} + +export interface CounterStats { + totals: Record; + buckets: CounterBucket[]; +} + +export interface CounterBucket { + hour: string; + counters: Record; } export interface JobCancellationResult { diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte index 5096f123..11c7570d 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte @@ -72,12 +72,15 @@ } } - // Reload orders on every navigation to this page (initial load + SPA navigations back) - afterNavigate(() => { + // Reload orders on SPA navigations back to this page + afterNavigate(({ type }) => { + // Skip the initial navigation β€” onMount handles the first load + if (type === 'enter') return; loadOrders(); }); onMount(() => { + loadOrders(); const unsubCreated = eventStream.onOrderCreated((event) => { toast.success('New order created'); diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte index d2010697..5fedf56a 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte @@ -72,12 +72,15 @@ } } - // Reload products on every navigation to this page (initial load + SPA navigations back) - afterNavigate(() => { + // Reload products on SPA navigations back to this page + afterNavigate(({ type }) => { + // Skip the initial navigation β€” onMount handles the first load + if (type === 'enter') return; loadProducts(); }); onMount(() => { + loadProducts(); const unsubCreated = eventStream.onProductCreated((event) => { toast.success('New product created'); diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte index aa6f3ef9..7c42d3ef 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte @@ -1,9 +1,10 @@ @@ -147,10 +159,10 @@

Monitor queue workers, job progress, and manage running jobs.

- -
@@ -171,19 +183,16 @@ {:else} -
+
- - + - - @@ -194,11 +203,13 @@ : 'hover:bg-gray-50'}" onclick={() => selectQueue(worker.queueName)} > - - - - - - - - - + + + {/each} @@ -312,7 +319,7 @@ {/if}
{#each dashboard.recentJobs as job (job.jobId)} -
+
@@ -341,7 +348,7 @@ {/if} {#if job.errorMessage} -
+
{job.errorMessage}
{/if} diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs index 74781fe6..4ddce86e 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs @@ -2,6 +2,7 @@ using System.Globalization; using Amazon.SQS; using Amazon.SQS.Model; +using Microsoft.Extensions.Logging; namespace Foundatio.Mediator.Distributed.Aws; @@ -15,13 +16,15 @@ public sealed class SqsQueueClient : IQueueClient private readonly IAmazonSQS _sqs; private readonly SqsQueueClientOptions _options; private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; private readonly ConcurrentDictionary _queueUrlCache = new(); - public SqsQueueClient(IAmazonSQS sqs, SqsQueueClientOptions? options = null, TimeProvider? timeProvider = null) + public SqsQueueClient(IAmazonSQS sqs, SqsQueueClientOptions? options = null, TimeProvider? timeProvider = null, ILogger? logger = null) { _sqs = sqs; _options = options ?? new SqsQueueClientOptions(); _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; } public async Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default) @@ -215,12 +218,33 @@ public async Task RenewTimeoutAsync(QueueMessage message, TimeSpan extension, Ca var queueUrl = await GetQueueUrlAsync(message.QueueName, cancellationToken).ConfigureAwait(false); var sqsMessage = GetNativeMessage(message); - await _sqs.ChangeMessageVisibilityAsync(new ChangeMessageVisibilityRequest + try { - QueueUrl = queueUrl, - ReceiptHandle = sqsMessage.ReceiptHandle, - VisibilityTimeout = (int)Math.Ceiling(extension.TotalSeconds) - }, cancellationToken).ConfigureAwait(false); + await _sqs.ChangeMessageVisibilityAsync(new ChangeMessageVisibilityRequest + { + QueueUrl = queueUrl, + ReceiptHandle = sqsMessage.ReceiptHandle, + VisibilityTimeout = (int)Math.Ceiling(extension.TotalSeconds) + }, cancellationToken).ConfigureAwait(false); + } + catch (ReceiptHandleIsInvalidException ex) + { + // Receipt handle expired or message already completed/deleted (e.g., leftover from a previous run). + // This is not fatal β€” the message is already gone from the queue. + _logger.LogDebug(ex, "Receipt handle invalid for message {MessageId} on {QueueName}, message may have been completed or expired", + message.Id, message.QueueName); + } + catch (MessageNotInflightException ex) + { + _logger.LogDebug(ex, "Message {MessageId} on {QueueName} is not in-flight, visibility timeout change skipped", + message.Id, message.QueueName); + } + catch (AmazonSQSException ex) when (ex.Message.Contains("does not exist or is not available", StringComparison.OrdinalIgnoreCase)) + { + // LocalStack may throw a generic AmazonSQSException instead of the specific types above. + _logger.LogDebug(ex, "Receipt handle for message {MessageId} on {QueueName} is no longer valid, visibility timeout change skipped", + message.Id, message.QueueName); + } } private async Task GetQueueUrlAsync(string queueName, CancellationToken cancellationToken) diff --git a/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs index 697024c6..fa70924e 100644 --- a/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs @@ -23,6 +23,10 @@ public async Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, { var db = _redis.GetDatabase(); var key = JobKey(state.JobId); + var score = state.CreatedUtc.ToUnixTimeMilliseconds(); + + // Read old status before overwriting (for status set migration) + var oldStatusValue = await db.HashGetAsync(key, "Status").ConfigureAwait(false); var entries = new HashEntry[] { @@ -32,7 +36,7 @@ public async Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, new("Status", ((int)state.Status).ToString(CultureInfo.InvariantCulture)), new("Progress", state.Progress.ToString(CultureInfo.InvariantCulture)), new("ProgressMessage", state.ProgressMessage ?? string.Empty), - new("CreatedUtc", state.CreatedUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)), + new("CreatedUtc", score.ToString(CultureInfo.InvariantCulture)), new("StartedUtc", state.StartedUtc?.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) ?? string.Empty), new("CompletedUtc", state.CompletedUtc?.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) ?? string.Empty), new("ErrorMessage", state.ErrorMessage ?? string.Empty), @@ -43,14 +47,27 @@ public async Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, // Add to the per-queue sorted set (scored by creation timestamp for ordering) var queueSetKey = QueueSetKey(state.QueueName); - await db.SortedSetAddAsync(queueSetKey, state.JobId, state.CreatedUtc.ToUnixTimeMilliseconds()).ConfigureAwait(false); + await db.SortedSetAddAsync(queueSetKey, state.JobId, score).ConfigureAwait(false); + + // Maintain per-status sorted sets + // Remove from old status set if status changed + if (!oldStatusValue.IsNullOrEmpty && int.TryParse(oldStatusValue.ToString(), out var oldStatusInt)) + { + var oldStatus = (QueueJobStatus)oldStatusInt; + if (oldStatus != state.Status) + await db.SortedSetRemoveAsync(StatusSetKey(state.QueueName, oldStatus), state.JobId).ConfigureAwait(false); + } - // Set TTL + // Add to current status set + await db.SortedSetAddAsync(StatusSetKey(state.QueueName, state.Status), state.JobId, score).ConfigureAwait(false); + + // Set TTL on all keys var ttl = expiry ?? _options.DefaultExpiry; if (ttl.HasValue) { await db.KeyExpireAsync(key, ttl.Value).ConfigureAwait(false); await db.KeyExpireAsync(queueSetKey, ttl.Value).ConfigureAwait(false); + await db.KeyExpireAsync(StatusSetKey(state.QueueName, state.Status), ttl.Value).ConfigureAwait(false); } } @@ -65,41 +82,77 @@ public async Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, return ParseJobState(entries); } - public async Task> GetJobsByQueueAsync(string queueName, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + public async Task UpdateJobStatusAsync(string jobId, QueueJobStatus status, DateTimeOffset? startedUtc = null, DateTimeOffset? completedUtc = null, string? errorMessage = null, int? progress = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); - var queueSetKey = QueueSetKey(queueName); + var key = JobKey(jobId); - // Get job IDs from sorted set in reverse order (newest first) - var jobIds = await db.SortedSetRangeByRankAsync(queueSetKey, skip, skip + take - 1, Order.Descending).ConfigureAwait(false); + // Read old status + queue name for sorted set migration + var fields = await db.HashGetAsync(key, ["Status", "QueueName", "CreatedUtc"]).ConfigureAwait(false); + if (fields[0].IsNullOrEmpty) + return; // Job doesn't exist - if (jobIds.Length == 0) - return []; + var queueName = fields[1].ToString(); + var createdScore = long.TryParse(fields[2].ToString(), out var cs) ? cs : 0d; + var oldStatusInt = int.TryParse(fields[0].ToString(), out var osi) ? osi : -1; + var oldStatus = (QueueJobStatus)oldStatusInt; + var now = DateTimeOffset.UtcNow; - // Pipeline all hash reads to avoid N+1 round-trips - var batch = db.CreateBatch(); - var tasks = new Task[jobIds.Length]; - for (int i = 0; i < jobIds.Length; i++) + // Build only the fields that need updating + var updates = new List { - if (jobIds[i].IsNullOrEmpty) - continue; + new("Status", ((int)status).ToString(CultureInfo.InvariantCulture)), + new("LastUpdatedUtc", now.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)) + }; - tasks[i] = batch.HashGetAllAsync(JobKey(jobIds[i].ToString())); - } - batch.Execute(); + if (startedUtc.HasValue) + updates.Add(new HashEntry("StartedUtc", startedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); + if (completedUtc.HasValue) + updates.Add(new HashEntry("CompletedUtc", completedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); + if (errorMessage is not null) + updates.Add(new HashEntry("ErrorMessage", errorMessage)); + if (progress.HasValue) + updates.Add(new HashEntry("Progress", progress.Value.ToString(CultureInfo.InvariantCulture))); - var results = new List(jobIds.Length); - for (int i = 0; i < tasks.Length; i++) + await db.HashSetAsync(key, updates.ToArray()).ConfigureAwait(false); + + // Migrate sorted sets if status changed + if (oldStatus != status) { - if (tasks[i] is null) - continue; + await db.SortedSetRemoveAsync(StatusSetKey(queueName, oldStatus), jobId).ConfigureAwait(false); + await db.SortedSetAddAsync(StatusSetKey(queueName, status), jobId, createdScore).ConfigureAwait(false); + } - var entries = await tasks[i].ConfigureAwait(false); - if (entries.Length > 0) - results.Add(ParseJobState(entries)); + // Refresh TTL + var ttl = expiry ?? _options.DefaultExpiry; + if (ttl.HasValue) + { + await db.KeyExpireAsync(key, ttl.Value).ConfigureAwait(false); + await db.KeyExpireAsync(StatusSetKey(queueName, status), ttl.Value).ConfigureAwait(false); } + } - return results; + public async Task UpdateJobProgressAsync(string jobId, int progress, string? progressMessage = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var key = JobKey(jobId); + + if (!await db.KeyExistsAsync(key).ConfigureAwait(false)) + return; + + var now = DateTimeOffset.UtcNow; + var updates = new HashEntry[] + { + new("Progress", progress.ToString(CultureInfo.InvariantCulture)), + new("ProgressMessage", progressMessage ?? string.Empty), + new("LastUpdatedUtc", now.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)) + }; + + await db.HashSetAsync(key, updates).ConfigureAwait(false); + + var ttl = expiry ?? _options.DefaultExpiry; + if (ttl.HasValue) + await db.KeyExpireAsync(key, ttl.Value).ConfigureAwait(false); } public async Task RequestCancellationAsync(string jobId, CancellationToken cancellationToken = default) @@ -142,130 +195,123 @@ public async Task RemoveJobStateAsync(string jobId, CancellationToken cancellati var db = _redis.GetDatabase(); var key = JobKey(jobId); - // Get queue name before deleting so we can clean up the sorted set - var queueName = await db.HashGetAsync(key, "QueueName").ConfigureAwait(false); + // Read queue name and status before deleting so we can clean up sorted sets + var fields = await db.HashGetAsync(key, ["QueueName", "Status"]).ConfigureAwait(false); + var queueName = fields[0]; + var statusValue = fields[1]; await db.KeyDeleteAsync(key).ConfigureAwait(false); await db.KeyDeleteAsync(CancelKey(jobId)).ConfigureAwait(false); if (!queueName.IsNullOrEmpty) - await db.SortedSetRemoveAsync(QueueSetKey(queueName.ToString()), jobId).ConfigureAwait(false); + { + var qn = queueName.ToString(); + await db.SortedSetRemoveAsync(QueueSetKey(qn), jobId).ConfigureAwait(false); + + // Remove from per-status sorted set + if (!statusValue.IsNullOrEmpty && int.TryParse(statusValue.ToString(), out var statusInt)) + await db.SortedSetRemoveAsync(StatusSetKey(qn, (QueueJobStatus)statusInt), jobId).ConfigureAwait(false); + } } public Task IncrementCounterAsync(string queueName, string counterName, long value = 1, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); - var key = CountersKey(queueName); - return db.HashIncrementAsync(key, counterName, value); + var bucketKey = CounterBucketKey(queueName, DateTimeOffset.UtcNow); + var task = db.HashIncrementAsync(bucketKey, counterName, value); + + // Auto-expire each hourly bucket after 48h so old buckets clean themselves up + _ = db.KeyExpireAsync(bucketKey, TimeSpan.FromHours(48), ExpireWhen.HasNoExpiry); + + return task; } - public async Task> GetCountersAsync(string queueName, CancellationToken cancellationToken = default) + public async Task GetCounterStatsAsync(string queueName, TimeSpan? window = null, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); - var key = CountersKey(queueName); - var entries = await db.HashGetAllAsync(key).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var effectiveWindow = window ?? TimeSpan.FromHours(24); + var startHour = TruncateToHour(now - effectiveWindow); + var endHour = TruncateToHour(now); + + // Build list of bucket keys to query + var hours = new List(); + for (var hour = startHour; hour <= endHour; hour = hour.AddHours(1)) + hours.Add(hour); + + // Pipeline all bucket reads in a single round-trip + var batch = db.CreateBatch(); + var tasks = new Task[hours.Count]; + for (int i = 0; i < hours.Count; i++) + tasks[i] = batch.HashGetAllAsync(CounterBucketKey(queueName, hours[i])); + batch.Execute(); + + var totals = new Dictionary(); + var buckets = new List(hours.Count); - var result = new Dictionary(entries.Length); - foreach (var entry in entries) + for (int i = 0; i < hours.Count; i++) { - if (entry.Value.TryParse(out long val)) - result[entry.Name.ToString()] = val; + var entries = await tasks[i].ConfigureAwait(false); + var counters = new Dictionary(entries.Length); + + foreach (var entry in entries) + { + if (entry.Value.TryParse(out long val)) + { + var name = entry.Name.ToString(); + counters[name] = val; + totals[name] = totals.GetValueOrDefault(name) + val; + } + } + + buckets.Add(new CounterBucket { Hour = hours[i], Counters = counters }); } - return result; + return new QueueCounterStats { Totals = totals, Buckets = buckets }; } private string JobKey(string jobId) => $"{_options.KeyPrefix}:{jobId}"; private string CancelKey(string jobId) => $"{_options.KeyPrefix}:{jobId}:cancel"; private string QueueSetKey(string queueName) => $"{_options.KeyPrefix}:queues:{queueName}"; - private string CountersKey(string queueName) => $"{_options.KeyPrefix}:counters:{queueName}"; + private string StatusSetKey(string queueName, QueueJobStatus status) => $"{_options.KeyPrefix}:queues:{queueName}:status:{(int)status}"; + private string CounterBucketKey(string queueName, DateTimeOffset timestamp) => $"{_options.KeyPrefix}:counters:{queueName}:{TruncateToHour(timestamp):yyyy-MM-ddTHH}"; + + private static DateTimeOffset TruncateToHour(DateTimeOffset timestamp) + => new(timestamp.Year, timestamp.Month, timestamp.Day, timestamp.Hour, 0, 0, TimeSpan.Zero); - public async Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + public async Task> GetJobsByStatusAsync(string queueName, QueueJobStatus status, int skip = 0, int take = 50, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); - var queueSetKey = QueueSetKey(queueName); - var prefix = _options.KeyPrefix; - - // Build status filter string for Lua (e.g., ",0,1,") - var statusFilter = "," + string.Join(",", statuses.Select(s => ((int)s).ToString(CultureInfo.InvariantCulture))) + ","; - - // Lua script: iterate sorted set in descending order, check Status hash field, collect matching job IDs - const string lua = """ - local queueKey = KEYS[1] - local prefix = ARGV[1] - local statusFilter = ARGV[2] - local skip = tonumber(ARGV[3]) - local take = tonumber(ARGV[4]) - - local all = redis.call('ZREVRANGE', queueKey, 0, -1) - local matched = 0 - local collected = 0 - local results = {} - - for _, jobId in ipairs(all) do - local status = redis.call('HGET', prefix .. ':' .. jobId, 'Status') - if status and string.find(statusFilter, ',' .. status .. ',', 1, true) then - matched = matched + 1 - if matched > skip then - table.insert(results, jobId) - collected = collected + 1 - if collected >= take then break end - end - end - end - - return results - """; - - var scriptResult = await db.ScriptEvaluateAsync(lua, - [queueSetKey], - [prefix, statusFilter, skip, take]).ConfigureAwait(false); - - var jobIds = (RedisResult[]?)scriptResult; - if (jobIds is null || jobIds.Length == 0) + var setKey = StatusSetKey(queueName, status); + + // O(take) β€” read only the page we need from the sorted set (newest first) + var members = await db.SortedSetRangeByRankAsync(setKey, skip, skip + take - 1, Order.Descending).ConfigureAwait(false); + + if (members.Length == 0) return []; - var results = new List(jobIds.Length); - foreach (var jobId in jobIds) + // Pipeline all hash reads + var batch = db.CreateBatch(); + var tasks = new Task[members.Length]; + for (int i = 0; i < members.Length; i++) + tasks[i] = batch.HashGetAllAsync(JobKey(members[i].ToString())); + batch.Execute(); + + var results = new List(members.Length); + for (int i = 0; i < tasks.Length; i++) { - var state = await GetJobStateAsync(jobId.ToString()!, cancellationToken).ConfigureAwait(false); - if (state is not null) - results.Add(state); + var entries = await tasks[i].ConfigureAwait(false); + if (entries.Length > 0) + results.Add(ParseJobState(entries)); } return results; } - public async Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + public Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); - var queueSetKey = QueueSetKey(queueName); - var prefix = _options.KeyPrefix; - var statusStr = ((int)status).ToString(CultureInfo.InvariantCulture); - - const string lua = """ - local queueKey = KEYS[1] - local prefix = ARGV[1] - local targetStatus = ARGV[2] - - local all = redis.call('ZRANGE', queueKey, 0, -1) - local count = 0 - - for _, jobId in ipairs(all) do - local status = redis.call('HGET', prefix .. ':' .. jobId, 'Status') - if status == targetStatus then - count = count + 1 - end - end - - return count - """; - - var result = await db.ScriptEvaluateAsync(lua, - [queueSetKey], - [prefix, statusStr]).ConfigureAwait(false); - - return (long)result; + return db.SortedSetLengthAsync(StatusSetKey(queueName, status)); } private static QueueJobState ParseJobState(HashEntry[] entries) diff --git a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs index 0e61bf2e..9f8501f7 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs @@ -67,6 +67,10 @@ public static IServiceCollection AddMediatorDistributed( // Collect queue names for startup initialization var infraOptions = GetOrAddInfrastructureOptions(services); + // Track which queue names already have a worker registered to avoid duplicates. + // Multiple handlers for the same message type share a single queue and worker. + var registeredQueues = new HashSet(StringComparer.OrdinalIgnoreCase); + // Register a QueueWorker for each [Queue]-decorated handler foreach (var handler in queueHandlers) { @@ -82,6 +86,12 @@ public static IServiceCollection AddMediatorDistributed( // Register this message type in the type resolver for safe deserialization typeResolver.Register(messageType); + // Skip if a worker is already registered for this queue name. + // Multiple handlers for the same message type (e.g., AuditEventHandler and + // NotificationEventHandler both handling OrderCreated) share one queue worker. + if (!registeredQueues.Add(queueName)) + continue; + // Apply group filtering var group = queueAttr?.Group; if (options.Group is not null && !string.Equals(options.Group, group, StringComparison.OrdinalIgnoreCase)) @@ -102,13 +112,21 @@ public static IServiceCollection AddMediatorDistributed( if (trackProgress) anyTrackProgress = true; + var concurrency = queueAttr?.Concurrency ?? 1; + var prefetchCount = queueAttr?.PrefetchCount ?? 0; + // Auto-scale prefetch to match concurrency when not explicitly set. + // This ensures each ReceiveAsync call can fill the consumer pipeline in a + // single round-trip, which is critical for fair distribution across nodes. + if (prefetchCount <= 0) + prefetchCount = concurrency; + var workerOptions = new QueueWorkerOptions { QueueName = queueName, MessageType = messageType, Registration = handler, - Concurrency = queueAttr?.Concurrency ?? 1, - PrefetchCount = queueAttr?.PrefetchCount ?? 1, + Concurrency = concurrency, + PrefetchCount = prefetchCount, VisibilityTimeout = visibilityTimeout, MaxRetries = queueAttr?.MaxRetries ?? 2, RetryPolicy = queueAttr?.RetryPolicy ?? QueueRetryPolicy.Exponential, diff --git a/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs b/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs index d298383b..4263fea8 100644 --- a/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs +++ b/src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs @@ -70,18 +70,6 @@ public async Task> HandleAsync(GetQueueWorker query, return ToSummary(worker, stats); } - /// - /// Gets tracked jobs for a specific queue, ordered by creation time descending. - /// - public async Task>> HandleAsync(GetQueueJobs query, CancellationToken ct) - { - if (_stateStore is null) - return Result.Error("Job state tracking is not configured."); - - var jobs = await _stateStore.GetJobsByQueueAsync(query.QueueName, query.Skip, query.Take, ct).ConfigureAwait(false); - return Result>.Ok(jobs); - } - /// /// Gets a dashboard view: queued count, active (processing) jobs, and recent terminal jobs. /// @@ -93,17 +81,26 @@ public async Task> HandleAsync(GetQueueJobDashboar var queuedCount = await _stateStore.GetJobCountByStatusAsync(query.QueueName, QueueJobStatus.Queued, ct).ConfigureAwait(false); var activeJobs = await _stateStore.GetJobsByStatusAsync( - query.QueueName, [QueueJobStatus.Processing], 0, 200, ct).ConfigureAwait(false); + query.QueueName, QueueJobStatus.Processing, 0, 200, ct).ConfigureAwait(false); + + var recentTerminalCount = query.RecentTerminalCount ?? 20; + var completedJobs = await _stateStore.GetJobsByStatusAsync(query.QueueName, QueueJobStatus.Completed, 0, recentTerminalCount, ct).ConfigureAwait(false); + var failedJobs = await _stateStore.GetJobsByStatusAsync(query.QueueName, QueueJobStatus.Failed, 0, recentTerminalCount, ct).ConfigureAwait(false); + var cancelledJobs = await _stateStore.GetJobsByStatusAsync(query.QueueName, QueueJobStatus.Cancelled, 0, recentTerminalCount, ct).ConfigureAwait(false); + + var recentJobs = completedJobs.Concat(failedJobs).Concat(cancelledJobs) + .OrderByDescending(j => j.CompletedUtc ?? j.LastUpdatedUtc) + .Take(recentTerminalCount) + .ToList(); - var recentJobs = await _stateStore.GetJobsByStatusAsync( - query.QueueName, [QueueJobStatus.Completed, QueueJobStatus.Failed, QueueJobStatus.Cancelled], - 0, query.RecentTerminalCount ?? 20, ct).ConfigureAwait(false); + var counterStats = await _stateStore.GetCounterStatsAsync(query.QueueName, TimeSpan.FromHours(24), ct).ConfigureAwait(false); return new QueueJobDashboardView { QueuedCount = queuedCount, ActiveJobs = activeJobs, - RecentJobs = recentJobs + RecentJobs = recentJobs, + CounterStats = counterStats }; } diff --git a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs index cc978b38..fa999621 100644 --- a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs @@ -14,16 +14,36 @@ public interface IQueueJobStateStore /// /// Retrieves the state for a specific job. - /// Implementations must return an independent copy so callers can mutate - /// properties without affecting stored data. /// /// The job state, or null if the job ID is not found or has expired. Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default); /// - /// Retrieves tracked jobs for a specific queue, ordered by creation time descending. + /// Atomically updates job status and related fields without requiring a prior read. + /// Implementations should update only the supplied fields plus LastUpdatedUtc. /// - Task> GetJobsByQueueAsync(string queueName, int skip = 0, int take = 50, CancellationToken cancellationToken = default); + /// The job identifier. + /// The new status. + /// When processing started (set when transitioning to ). + /// When the job reached a terminal state. + /// Error details (set when transitioning to ). + /// Progress percentage to set alongside the status change. + /// Optional sliding expiry for the entry. + /// Cancellation token. + Task UpdateJobStatusAsync(string jobId, QueueJobStatus status, DateTimeOffset? startedUtc = null, DateTimeOffset? completedUtc = null, string? errorMessage = null, int? progress = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + /// Atomically updates job progress and optional message without requiring a prior read. + /// Implementations should also update LastUpdatedUtc. + /// + /// The job identifier. + /// Progress percentage (0–100). + /// Optional description of current work. + /// Optional sliding expiry for the entry. + /// Cancellation token. + Task UpdateJobProgressAsync(string jobId, int progress, string? progressMessage = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + => Task.CompletedTask; /// /// Requests cancellation of a job. The worker will observe this on the next @@ -44,8 +64,9 @@ public interface IQueueJobStateStore Task RemoveJobStateAsync(string jobId, CancellationToken cancellationToken = default); /// - /// Atomically increments a named counter for a queue. + /// Atomically increments a named counter for a queue within the current hourly bucket. /// Used to track messages processed, failed, and dead-lettered across all nodes. + /// Implementations should bucket by UTC hour for time-windowed queries. /// /// The queue name. /// Counter name (e.g., "processed", "failed", "dead_lettered"). @@ -55,35 +76,29 @@ Task IncrementCounterAsync(string queueName, string counterName, long value = 1, => Task.CompletedTask; /// - /// Retrieves all counters for a queue (e.g., processed, failed, dead_lettered). + /// Retrieves counter statistics for a queue over a time window, including per-hour buckets + /// for sparkline rendering and aggregated totals. /// /// The queue name. + /// The time window to query. Defaults to 24 hours. /// Cancellation token. - /// A dictionary of counter name to value. Empty if no counters exist. - Task> GetCountersAsync(string queueName, CancellationToken cancellationToken = default) - => Task.FromResult>(new Dictionary()); + /// Counter totals and per-hour buckets ordered oldest to newest. + Task GetCounterStatsAsync(string queueName, TimeSpan? window = null, CancellationToken cancellationToken = default) + => Task.FromResult(new QueueCounterStats + { + Totals = new Dictionary(), + Buckets = [] + }); /// - /// Retrieves tracked jobs filtered by one or more statuses, ordered by creation time descending. + /// Retrieves tracked jobs for a given status, ordered by creation time descending. /// - async Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) - { - // Default: fall back to GetJobsByQueueAsync and filter in memory - var all = await GetJobsByQueueAsync(queueName, 0, skip + take + 500, cancellationToken).ConfigureAwait(false); - var statusSet = new HashSet(statuses); - return all - .Where(j => statusSet.Contains(j.Status)) - .Skip(skip) - .Take(take) - .ToList(); - } + Task> GetJobsByStatusAsync(string queueName, QueueJobStatus status, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + => Task.FromResult>([]); /// /// Counts jobs in a specific status for a queue. /// - async Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) - { - var all = await GetJobsByQueueAsync(queueName, 0, int.MaxValue, cancellationToken).ConfigureAwait(false); - return all.Count(j => j.Status == status); - } + Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + => Task.FromResult(0L); } diff --git a/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs index b69769b6..a1e69c27 100644 --- a/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs @@ -11,7 +11,7 @@ public sealed class InMemoryQueueJobStateStore : IQueueJobStateStore { private readonly ConcurrentDictionary _jobs = new(); private readonly ConcurrentDictionary _cancellations = new(); - private readonly ConcurrentDictionary> _counters = new(); + private readonly ConcurrentDictionary> _counterBuckets = new(); private readonly TimeProvider _timeProvider; private int _accessCount; @@ -35,7 +35,7 @@ public Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, Cance public Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default) { if (_jobs.TryGetValue(jobId, out var entry) && !IsExpired(entry)) - return Task.FromResult(entry.State.Clone()); + return Task.FromResult(entry.State); // Remove expired entry on access if (entry is not null) @@ -47,18 +47,43 @@ public Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, Cance return Task.FromResult(null); } - public Task> GetJobsByQueueAsync(string queueName, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + public Task UpdateJobStatusAsync(string jobId, QueueJobStatus status, DateTimeOffset? startedUtc = null, DateTimeOffset? completedUtc = null, string? errorMessage = null, int? progress = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) { + if (!_jobs.TryGetValue(jobId, out var entry) || IsExpired(entry)) + return Task.CompletedTask; + var now = _timeProvider.GetUtcNow(); - var results = _jobs.Values - .Where(e => !IsExpired(e, now) && string.Equals(e.State.QueueName, queueName, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(e => e.State.CreatedUtc) - .Skip(skip) - .Take(take) - .Select(e => e.State.Clone()) - .ToList(); + var expiresAt = expiry.HasValue ? now + expiry.Value : entry.ExpiresAt; + var updated = entry.State with + { + Status = status, + StartedUtc = startedUtc ?? entry.State.StartedUtc, + CompletedUtc = completedUtc ?? entry.State.CompletedUtc, + ErrorMessage = errorMessage ?? entry.State.ErrorMessage, + Progress = progress ?? entry.State.Progress, + LastUpdatedUtc = now + }; + + _jobs[jobId] = new JobEntry(updated, expiresAt); + return Task.CompletedTask; + } - return Task.FromResult>(results); + public Task UpdateJobProgressAsync(string jobId, int progress, string? progressMessage = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + if (!_jobs.TryGetValue(jobId, out var entry) || IsExpired(entry)) + return Task.CompletedTask; + + var now = _timeProvider.GetUtcNow(); + var expiresAt = expiry.HasValue ? now + expiry.Value : entry.ExpiresAt; + var updated = entry.State with + { + Progress = progress, + ProgressMessage = progressMessage, + LastUpdatedUtc = now + }; + + _jobs[jobId] = new JobEntry(updated, expiresAt); + return Task.CompletedTask; } public Task RequestCancellationAsync(string jobId, CancellationToken cancellationToken = default) @@ -88,31 +113,62 @@ public Task RemoveJobStateAsync(string jobId, CancellationToken cancellationToke public Task IncrementCounterAsync(string queueName, string counterName, long value = 1, CancellationToken cancellationToken = default) { - var queueCounters = _counters.GetOrAdd(queueName, _ => new ConcurrentDictionary()); - queueCounters.AddOrUpdate(counterName, value, (_, existing) => existing + value); + var bucketKey = GetBucketKey(queueName, _timeProvider.GetUtcNow()); + var bucket = _counterBuckets.GetOrAdd(bucketKey, _ => new ConcurrentDictionary()); + bucket.AddOrUpdate(counterName, value, (_, existing) => existing + value); return Task.CompletedTask; } - public Task> GetCountersAsync(string queueName, CancellationToken cancellationToken = default) + public Task GetCounterStatsAsync(string queueName, TimeSpan? window = null, CancellationToken cancellationToken = default) { - if (_counters.TryGetValue(queueName, out var queueCounters)) - return Task.FromResult>(new Dictionary(queueCounters)); + var now = _timeProvider.GetUtcNow(); + var effectiveWindow = window ?? TimeSpan.FromHours(24); + var startHour = TruncateToHour(now - effectiveWindow); + var endHour = TruncateToHour(now); + + var totals = new Dictionary(); + var buckets = new List(); - return Task.FromResult>(new Dictionary()); + for (var hour = startHour; hour <= endHour; hour = hour.AddHours(1)) + { + var bucketKey = GetBucketKey(queueName, hour); + var counters = new Dictionary(); + + if (_counterBuckets.TryGetValue(bucketKey, out var bucket)) + { + foreach (var kvp in bucket) + { + counters[kvp.Key] = kvp.Value; + totals[kvp.Key] = totals.GetValueOrDefault(kvp.Key) + kvp.Value; + } + } + + buckets.Add(new CounterBucket { Hour = hour, Counters = counters }); + } + + return Task.FromResult(new QueueCounterStats { Totals = totals, Buckets = buckets }); } - public Task> GetJobsByStatusAsync(string queueName, QueueJobStatus[] statuses, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + private static string GetBucketKey(string queueName, DateTimeOffset timestamp) + { + var hour = TruncateToHour(timestamp); + return $"{queueName}:{hour:yyyy-MM-ddTHH}"; + } + + private static DateTimeOffset TruncateToHour(DateTimeOffset timestamp) + => new(timestamp.Year, timestamp.Month, timestamp.Day, timestamp.Hour, 0, 0, TimeSpan.Zero); + + public Task> GetJobsByStatusAsync(string queueName, QueueJobStatus status, int skip = 0, int take = 50, CancellationToken cancellationToken = default) { var now = _timeProvider.GetUtcNow(); - var statusSet = new HashSet(statuses); var results = _jobs.Values .Where(e => !IsExpired(e, now) && string.Equals(e.State.QueueName, queueName, StringComparison.OrdinalIgnoreCase) - && statusSet.Contains(e.State.Status)) + && e.State.Status == status) .OrderByDescending(e => e.State.CreatedUtc) .Skip(skip) .Take(take) - .Select(e => e.State.Clone()) + .Select(e => e.State) .ToList(); return Task.FromResult>(results); diff --git a/src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs b/src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs index f859de4b..6b2a0c5d 100644 --- a/src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs +++ b/src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs @@ -10,11 +10,6 @@ public record GetQueueWorkers; /// public record GetQueueWorker(string QueueName); -/// -/// Gets tracked jobs for a specific queue. -/// -public record GetQueueJobs(string QueueName, int Skip = 0, int Take = 50); - /// /// Gets a dashboard view of jobs for a queue: queued count, active jobs, and recent terminal jobs. /// @@ -69,4 +64,10 @@ public record QueueJobDashboardView /// Recently completed, failed, or cancelled jobs. public required IReadOnlyList RecentJobs { get; init; } + + /// + /// Counter statistics with per-hour buckets for sparkline rendering. + /// Includes totals and hourly breakdown of processed, failed, and dead-lettered counts. + /// + public QueueCounterStats? CounterStats { get; init; } } diff --git a/src/Foundatio.Mediator.Distributed/QueueAttribute.cs b/src/Foundatio.Mediator.Distributed/QueueAttribute.cs index 1884db45..91af80f8 100644 --- a/src/Foundatio.Mediator.Distributed/QueueAttribute.cs +++ b/src/Foundatio.Mediator.Distributed/QueueAttribute.cs @@ -50,10 +50,11 @@ public sealed class QueueAttribute : Attribute public int Concurrency { get; set; } = 1; /// - /// Number of messages to fetch per receive batch. Default is 1. - /// Higher values reduce round-trips to the transport at the cost of larger working sets. + /// Number of messages to fetch per receive batch. + /// When 0 (the default), automatically matches + /// so each receive call can fill the consumer pipeline in a single round-trip. /// - public int PrefetchCount { get; set; } = 1; + public int PrefetchCount { get; set; } /// /// Queue group name for selective hosting. When set, only workers configured diff --git a/src/Foundatio.Mediator.Distributed/QueueContext.cs b/src/Foundatio.Mediator.Distributed/QueueContext.cs index 0f167010..011d5207 100644 --- a/src/Foundatio.Mediator.Distributed/QueueContext.cs +++ b/src/Foundatio.Mediator.Distributed/QueueContext.cs @@ -65,20 +65,20 @@ public class QueueContext /// is still actively working. This acts as a heartbeat keep-alive that extends the /// message visibility by the configured timeout. Set by the worker infrastructure. /// - public Func? OnReportProgress { get; init; } + internal Func? OnReportProgress { get; init; } /// /// Delegate invoked by /// to update progress percentage and message in the state store. /// Set by the worker infrastructure when progress tracking is enabled. /// - public Func? OnReportDetailedProgress { get; init; } + internal Func? OnReportDetailedProgress { get; init; } /// /// Delegate invoked by to extend the message lock /// or visibility timeout by a specific duration. Set by the worker infrastructure. /// - public Func? OnRenewTimeout { get; init; } + internal Func? OnRenewTimeout { get; init; } /// /// Reports that the handler is still actively processing the message. diff --git a/src/Foundatio.Mediator.Distributed/QueueCounterStats.cs b/src/Foundatio.Mediator.Distributed/QueueCounterStats.cs new file mode 100644 index 00000000..cf58c222 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueCounterStats.cs @@ -0,0 +1,35 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Counter statistics for a queue, including totals and per-hour buckets for sparkline rendering. +/// +public sealed record QueueCounterStats +{ + /// + /// Sum of all counters across the requested time window. + /// Keys are counter names (e.g., "processed", "failed", "dead_lettered"). + /// + public required IReadOnlyDictionary Totals { get; init; } + + /// + /// Per-hour counter values ordered oldest to newest, suitable for sparkline rendering. + /// Each bucket represents one UTC hour. + /// + public required IReadOnlyList Buckets { get; init; } +} + +/// +/// Counter values for a single hour. +/// +public sealed record CounterBucket +{ + /// + /// The UTC hour this bucket represents (truncated to the hour). + /// + public required DateTimeOffset Hour { get; init; } + + /// + /// Counter values for this hour. Keys are counter names. + /// + public required IReadOnlyDictionary Counters { get; init; } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueJobState.cs b/src/Foundatio.Mediator.Distributed/QueueJobState.cs index 7b533754..2949fadf 100644 --- a/src/Foundatio.Mediator.Distributed/QueueJobState.cs +++ b/src/Foundatio.Mediator.Distributed/QueueJobState.cs @@ -2,8 +2,9 @@ namespace Foundatio.Mediator.Distributed; /// /// Represents the current state of a queue job being tracked. +/// Immutable β€” use with { } expressions to create modified copies. /// -public sealed class QueueJobState +public sealed record QueueJobState { /// /// The unique identifier for this job, generated at enqueue time. @@ -23,17 +24,17 @@ public sealed class QueueJobState /// /// The current status of the job. /// - public QueueJobStatus Status { get; set; } = QueueJobStatus.Queued; + public QueueJobStatus Status { get; init; } = QueueJobStatus.Queued; /// /// Progress percentage (0–100). Updated by the handler via . /// - public int Progress { get; set; } + public int Progress { get; init; } /// /// Optional message describing what the job is currently doing. /// - public string? ProgressMessage { get; set; } + public string? ProgressMessage { get; init; } /// /// When the job was created (enqueued). @@ -43,41 +44,22 @@ public sealed class QueueJobState /// /// When the worker started processing the job. /// - public DateTimeOffset? StartedUtc { get; set; } + public DateTimeOffset? StartedUtc { get; init; } /// /// When the job reached a terminal state (Completed, Failed, or Cancelled). /// - public DateTimeOffset? CompletedUtc { get; set; } + public DateTimeOffset? CompletedUtc { get; init; } /// /// Error message when the job has failed. /// - public string? ErrorMessage { get; set; } + public string? ErrorMessage { get; init; } /// /// The last time this state was updated. /// - public DateTimeOffset LastUpdatedUtc { get; set; } - - /// - /// Creates a shallow copy of this state. Used by stores to return independent - /// snapshots so callers can mutate properties without affecting stored data. - /// - public QueueJobState Clone() => new() - { - JobId = JobId, - QueueName = QueueName, - MessageType = MessageType, - Status = Status, - Progress = Progress, - ProgressMessage = ProgressMessage, - CreatedUtc = CreatedUtc, - StartedUtc = StartedUtc, - CompletedUtc = CompletedUtc, - ErrorMessage = ErrorMessage, - LastUpdatedUtc = LastUpdatedUtc - }; + public DateTimeOffset LastUpdatedUtc { get; init; } } /// diff --git a/src/Foundatio.Mediator.Distributed/QueueWorker.cs b/src/Foundatio.Mediator.Distributed/QueueWorker.cs index 11cf0c6e..fc5f27eb 100644 --- a/src/Foundatio.Mediator.Distributed/QueueWorker.cs +++ b/src/Foundatio.Mediator.Distributed/QueueWorker.cs @@ -47,8 +47,7 @@ public QueueWorker( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (_workerInfo is not null) - _workerInfo._isRunning = true; + _workerInfo?.SetRunning(true); try { @@ -84,8 +83,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } finally { - if (_workerInfo is not null) - _workerInfo._isRunning = false; + _workerInfo?.SetRunning(false); } } @@ -157,8 +155,7 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s activity?.SetTag("messaging.dead_letter", true); activity?.SetTag("messaging.dead_letter.reason", "MaxRetriesExceeded"); - if (_workerInfo is not null) - Interlocked.Increment(ref _workerInfo._messagesDeadLettered); + _workerInfo?.IncrementDeadLettered(); if (_stateStore is not null) await _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken).ConfigureAwait(false); @@ -187,17 +184,7 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s { // Update state to Processing if (trackProgress && jobId is not null) - { - var now = _timeProvider.GetUtcNow(); - var state = await _stateStore!.GetJobStateAsync(jobId, stoppingToken).ConfigureAwait(false); - if (state is not null) - { - state.Status = QueueJobStatus.Processing; - state.StartedUtc = now; - state.LastUpdatedUtc = now; - await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, stoppingToken).ConfigureAwait(false); - } - } + await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Processing, startedUtc: _timeProvider.GetUtcNow(), expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); // Deserialize body to typed message var typedMessage = JsonSerializer.Deserialize(message.Body.Span, _options.MessageType, _jsonOptions); @@ -206,7 +193,8 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s _logger.LogWarning("Failed to deserialize message {MessageId} from {QueueName} as {MessageType}", message.Id, _options.QueueName, _options.MessageType.Name); - await UpdateJobStateFailed(jobId, $"Deserialization returned null for type {_options.MessageType.Name}", stoppingToken).ConfigureAwait(false); + if (jobId is not null && _stateStore is not null) + await _stateStore.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: $"Deserialization returned null for type {_options.MessageType.Name}", expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); await DeadLetterAsync(message, $"Deserialization returned null for type {_options.MessageType.Name}").ConfigureAwait(false); return; } @@ -239,14 +227,14 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s if (_options.AutoComplete) await _client.CompleteAsync(message, stoppingToken).ConfigureAwait(false); - if (_workerInfo is not null) - Interlocked.Increment(ref _workerInfo._messagesProcessed); + _workerInfo?.IncrementProcessed(); if (_stateStore is not null) await _stateStore.IncrementCounterAsync(_options.QueueName, "processed", 1, stoppingToken).ConfigureAwait(false); // Update state to Completed - await UpdateJobStateCompleted(jobId, stoppingToken).ConfigureAwait(false); + if (jobId is not null && _stateStore is not null) + await _stateStore.UpdateJobStatusAsync(jobId, QueueJobStatus.Completed, completedUtc: _timeProvider.GetUtcNow(), progress: 100, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -260,24 +248,26 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s var wasCancellationRequested = await _stateStore!.IsCancellationRequestedAsync(jobId, stoppingToken).ConfigureAwait(false); if (wasCancellationRequested) { - _logger.LogInformation("Message {MessageId} on {QueueName} was cancelled (job {JobId})", message.Id, _options.QueueName, jobId); - await UpdateJobStateCancelled(jobId, stoppingToken).ConfigureAwait(false); + _logger.LogInformation("Message {MessageId} on {QueueName} was cancelled by user (job {JobId})", message.Id, _options.QueueName, jobId); + await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Cancelled, completedUtc: _timeProvider.GetUtcNow(), expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + + // User cancellation is a normal completion β€” complete the message so it + // doesn't get retried or dead-lettered. if (_options.AutoComplete) await _client.CompleteAsync(message, stoppingToken).ConfigureAwait(false); } else { _logger.LogWarning("Message {MessageId} on {QueueName} timed out after {Timeout}", message.Id, _options.QueueName, _options.VisibilityTimeout); - await UpdateJobStateFailed(jobId, $"Timed out after {_options.VisibilityTimeout}", stoppingToken).ConfigureAwait(false); + await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: $"Timed out after {_options.VisibilityTimeout}", expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); if (_options.AutoComplete) await AbandonAsync(message, stoppingToken).ConfigureAwait(false); - } - if (_workerInfo is not null) - Interlocked.Increment(ref _workerInfo._messagesFailed); + _workerInfo?.IncrementFailed(); - if (_stateStore is not null) - await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + if (_stateStore is not null) + await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + } } catch (OperationCanceledException) { @@ -286,8 +276,7 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s if (_options.AutoComplete) await AbandonAsync(message, stoppingToken).ConfigureAwait(false); - if (_workerInfo is not null) - Interlocked.Increment(ref _workerInfo._messagesFailed); + _workerInfo?.IncrementFailed(); if (_stateStore is not null) await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); @@ -297,13 +286,13 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s _logger.LogError(ex, "Error processing message {MessageId} on {QueueName} (attempt {DequeueCount}/{MaxAttempts})", message.Id, _options.QueueName, message.DequeueCount, _options.MaxRetries + 1); - await UpdateJobStateFailed(jobId, ex.Message, stoppingToken).ConfigureAwait(false); + if (jobId is not null && _stateStore is not null) + await _stateStore.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: ex.Message, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); if (_options.AutoComplete) await AbandonAsync(message, stoppingToken).ConfigureAwait(false); - if (_workerInfo is not null) - Interlocked.Increment(ref _workerInfo._messagesFailed); + _workerInfo?.IncrementFailed(); if (_stateStore is not null) await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); @@ -351,57 +340,7 @@ private async Task UpdateJobProgressAsync(string jobId, int percent, string? mes if (await _stateStore.IsCancellationRequestedAsync(jobId, ct).ConfigureAwait(false)) throw new OperationCanceledException("Job cancellation was requested."); - var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); - if (state is null) return; - - state.Progress = Math.Clamp(percent, 0, 100); - state.ProgressMessage = message; - state.LastUpdatedUtc = _timeProvider.GetUtcNow(); - await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); - } - - private async Task UpdateJobStateCompleted(string? jobId, CancellationToken ct) - { - if (jobId is null || _stateStore is null) return; - - var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); - if (state is null) return; - - var now = _timeProvider.GetUtcNow(); - state.Status = QueueJobStatus.Completed; - state.Progress = 100; - state.CompletedUtc = now; - state.LastUpdatedUtc = now; - await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); - } - - private async Task UpdateJobStateFailed(string? jobId, string errorMessage, CancellationToken ct) - { - if (jobId is null || _stateStore is null) return; - - var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); - if (state is null) return; - - var now = _timeProvider.GetUtcNow(); - state.Status = QueueJobStatus.Failed; - state.ErrorMessage = errorMessage; - state.CompletedUtc = now; - state.LastUpdatedUtc = now; - await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); - } - - private async Task UpdateJobStateCancelled(string? jobId, CancellationToken ct) - { - if (jobId is null || _stateStore is null) return; - - var state = await _stateStore.GetJobStateAsync(jobId, ct).ConfigureAwait(false); - if (state is null) return; - - var now = _timeProvider.GetUtcNow(); - state.Status = QueueJobStatus.Cancelled; - state.CompletedUtc = now; - state.LastUpdatedUtc = now; - await _stateStore.SetJobStateAsync(state, s_defaultStateExpiry, ct).ConfigureAwait(false); + await _stateStore.UpdateJobProgressAsync(jobId, Math.Clamp(percent, 0, 100), message, s_defaultStateExpiry, ct).ConfigureAwait(false); } private async Task AbandonAsync(QueueMessage message, CancellationToken cancellationToken) diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs index 79de126d..09971bcf 100644 --- a/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs @@ -53,10 +53,10 @@ public sealed class QueueWorkerInfo // --- Runtime stats (updated atomically by QueueWorker) --- - internal long _messagesProcessed; - internal long _messagesFailed; - internal long _messagesDeadLettered; - internal volatile bool _isRunning; + private long _messagesProcessed; + private long _messagesFailed; + private long _messagesDeadLettered; + private volatile bool _isRunning; /// /// Total messages processed successfully since startup. @@ -77,4 +77,9 @@ public sealed class QueueWorkerInfo /// Whether the worker is currently running. /// public bool IsRunning => _isRunning; + + internal void IncrementProcessed() => Interlocked.Increment(ref _messagesProcessed); + internal void IncrementFailed() => Interlocked.Increment(ref _messagesFailed); + internal void IncrementDeadLettered() => Interlocked.Increment(ref _messagesDeadLettered); + internal void SetRunning(bool running) => _isRunning = running; } diff --git a/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs b/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs index 78bbaabb..a85d5d80 100644 --- a/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs @@ -73,10 +73,8 @@ public async Task SetJobState_UpdatesExisting() var state = CreateJobState(); await store.SetJobStateAsync(state, cancellationToken: CT); - state.Status = QueueJobStatus.Processing; - state.Progress = 50; - state.ProgressMessage = "Half done"; - await store.SetJobStateAsync(state, cancellationToken: CT); + var updated = state with { Status = QueueJobStatus.Processing, Progress = 50, ProgressMessage = "Half done" }; + await store.SetJobStateAsync(updated, cancellationToken: CT); var retrieved = await store.GetJobStateAsync("job-1", CT); Assert.NotNull(retrieved); @@ -153,25 +151,25 @@ public async Task SetAndGet_PreservesTerminalStateFields() Assert.Equal(now.ToUnixTimeMilliseconds(), retrieved.CompletedUtc!.Value.ToUnixTimeMilliseconds()); } - // ── GetJobsByQueue ───────────────────────────────────────────────────── + // ── GetJobsByStatus ───────────────────────────────────────────────────── [Fact] - public async Task GetJobsByQueue_ReturnsMatchingJobs() + public async Task GetJobsByStatus_ReturnsMatchingJobs() { var store = CreateStore(); await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), cancellationToken: CT); await store.SetJobStateAsync(CreateJobState("job-2", "QueueA"), cancellationToken: CT); await store.SetJobStateAsync(CreateJobState("job-3", "QueueB"), cancellationToken: CT); - var jobsA = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + var jobsA = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); Assert.Equal(2, jobsA.Count); - var jobsB = await store.GetJobsByQueueAsync("QueueB", cancellationToken: CT); + var jobsB = await store.GetJobsByStatusAsync("QueueB", QueueJobStatus.Queued, cancellationToken: CT); Assert.Single(jobsB); } [Fact] - public async Task GetJobsByQueue_OrdersByCreatedUtcDescending() + public async Task GetJobsByStatus_OrdersByCreatedUtcDescending() { var store = CreateStore(); var baseTime = DateTimeOffset.UtcNow; @@ -180,7 +178,7 @@ public async Task GetJobsByQueue_OrdersByCreatedUtcDescending() await store.SetJobStateAsync(CreateJobState("job-2", "QueueA", createdUtc: baseTime.AddMinutes(1)), cancellationToken: CT); await store.SetJobStateAsync(CreateJobState("job-3", "QueueA", createdUtc: baseTime.AddMinutes(2)), cancellationToken: CT); - var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + var jobs = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); Assert.Equal(3, jobs.Count); Assert.Equal("job-3", jobs[0].JobId); // newest first Assert.Equal("job-2", jobs[1].JobId); @@ -188,7 +186,7 @@ public async Task GetJobsByQueue_OrdersByCreatedUtcDescending() } [Fact] - public async Task GetJobsByQueue_SupportsPagination() + public async Task GetJobsByStatus_SupportsPagination() { var store = CreateStore(); var baseTime = DateTimeOffset.UtcNow; @@ -200,13 +198,13 @@ await store.SetJobStateAsync( cancellationToken: CT); } - var page1 = await store.GetJobsByQueueAsync("QueueA", skip: 0, take: 2, cancellationToken: CT); + var page1 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 0, take: 2, cancellationToken: CT); Assert.Equal(2, page1.Count); - var page2 = await store.GetJobsByQueueAsync("QueueA", skip: 2, take: 2, cancellationToken: CT); + var page2 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 2, take: 2, cancellationToken: CT); Assert.Equal(2, page2.Count); - var page3 = await store.GetJobsByQueueAsync("QueueA", skip: 4, take: 2, cancellationToken: CT); + var page3 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 4, take: 2, cancellationToken: CT); Assert.Single(page3); // No overlap between pages @@ -215,10 +213,10 @@ await store.SetJobStateAsync( } [Fact] - public async Task GetJobsByQueue_EmptyQueue_ReturnsEmpty() + public async Task GetJobsByStatus_EmptyQueue_ReturnsEmpty() { var store = CreateStore(); - var jobs = await store.GetJobsByQueueAsync("EmptyQueue", cancellationToken: CT); + var jobs = await store.GetJobsByStatusAsync("EmptyQueue", QueueJobStatus.Queued, cancellationToken: CT); Assert.Empty(jobs); } @@ -233,12 +231,19 @@ public async Task GetJobsByStatus_FiltersCorrectly() await store.SetJobStateAsync(CreateJobState("job-3", "Q", QueueJobStatus.Completed), cancellationToken: CT); await store.SetJobStateAsync(CreateJobState("job-4", "Q", QueueJobStatus.Failed), cancellationToken: CT); - var active = await store.GetJobsByStatusAsync("Q", [QueueJobStatus.Queued, QueueJobStatus.Processing], cancellationToken: CT); - Assert.Equal(2, active.Count); - Assert.All(active, j => Assert.True(j.Status is QueueJobStatus.Queued or QueueJobStatus.Processing)); + var queued = await store.GetJobsByStatusAsync("Q", QueueJobStatus.Queued, cancellationToken: CT); + Assert.Single(queued); + Assert.Equal(QueueJobStatus.Queued, queued[0].Status); + + var processing = await store.GetJobsByStatusAsync("Q", QueueJobStatus.Processing, cancellationToken: CT); + Assert.Single(processing); + Assert.Equal(QueueJobStatus.Processing, processing[0].Status); - var terminal = await store.GetJobsByStatusAsync("Q", [QueueJobStatus.Completed, QueueJobStatus.Failed], cancellationToken: CT); - Assert.Equal(2, terminal.Count); + var completed = await store.GetJobsByStatusAsync("Q", QueueJobStatus.Completed, cancellationToken: CT); + Assert.Single(completed); + + var failed = await store.GetJobsByStatusAsync("Q", QueueJobStatus.Failed, cancellationToken: CT); + Assert.Single(failed); } [Fact] @@ -320,7 +325,7 @@ public async Task RemoveJobState_RemovesEntry() } [Fact] - public async Task RemoveJobState_RemovesFromQueueListing() + public async Task RemoveJobState_RemovesFromStatusListing() { var store = CreateStore(); await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), cancellationToken: CT); @@ -328,7 +333,7 @@ public async Task RemoveJobState_RemovesFromQueueListing() await store.RemoveJobStateAsync("job-1", CT); - var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + var jobs = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); Assert.Single(jobs); Assert.Equal("job-2", jobs[0].JobId); } @@ -373,9 +378,9 @@ public async Task IncrementCounter_CreatesAndIncrements() await store.IncrementCounterAsync("TestQueue", "processed", 1, CT); await store.IncrementCounterAsync("TestQueue", "failed", 1, CT); - var counters = await store.GetCountersAsync("TestQueue", CT); - Assert.Equal(2, counters["processed"]); - Assert.Equal(1, counters["failed"]); + var stats = await store.GetCounterStatsAsync("TestQueue", TimeSpan.FromHours(1), CT); + Assert.Equal(2, stats.Totals["processed"]); + Assert.Equal(1, stats.Totals["failed"]); } [Fact] @@ -386,16 +391,17 @@ public async Task IncrementCounter_SupportsCustomIncrements() await store.IncrementCounterAsync("TestQueue", "processed", 5, CT); await store.IncrementCounterAsync("TestQueue", "processed", 10, CT); - var counters = await store.GetCountersAsync("TestQueue", CT); - Assert.Equal(15, counters["processed"]); + var stats = await store.GetCounterStatsAsync("TestQueue", TimeSpan.FromHours(1), CT); + Assert.Equal(15, stats.Totals["processed"]); } [Fact] - public async Task GetCounters_EmptyQueue_ReturnsEmpty() + public async Task GetCounterStats_EmptyQueue_ReturnsEmptyTotals() { var store = CreateStore(); - var counters = await store.GetCountersAsync("NonExistent", CT); - Assert.Empty(counters); + var stats = await store.GetCounterStatsAsync("NonExistent", TimeSpan.FromHours(1), CT); + Assert.Empty(stats.Totals); + Assert.NotEmpty(stats.Buckets); // Should still have hourly bucket entries (with empty counters) } [Fact] @@ -406,10 +412,30 @@ public async Task Counters_IsolatedPerQueue() await store.IncrementCounterAsync("Queue1", "processed", 3, CT); await store.IncrementCounterAsync("Queue2", "processed", 7, CT); - var counters1 = await store.GetCountersAsync("Queue1", CT); - var counters2 = await store.GetCountersAsync("Queue2", CT); + var stats1 = await store.GetCounterStatsAsync("Queue1", TimeSpan.FromHours(1), CT); + var stats2 = await store.GetCounterStatsAsync("Queue2", TimeSpan.FromHours(1), CT); + + Assert.Equal(3, stats1.Totals["processed"]); + Assert.Equal(7, stats2.Totals["processed"]); + } + + [Fact] + public async Task GetCounterStats_ReturnsBucketsForWindow() + { + var store = CreateStore(); + + await store.IncrementCounterAsync("TestQueue", "processed", 5, CT); + + var stats = await store.GetCounterStatsAsync("TestQueue", TimeSpan.FromHours(24), CT); + + // Should have 25 buckets (24 hours ago through current hour) + Assert.Equal(25, stats.Buckets.Count); + + // At least one bucket should have the counter + Assert.Contains(stats.Buckets, b => b.Counters.GetValueOrDefault("processed") > 0); - Assert.Equal(3, counters1["processed"]); - Assert.Equal(7, counters2["processed"]); + // Buckets should be ordered oldest to newest + for (int i = 1; i < stats.Buckets.Count; i++) + Assert.True(stats.Buckets[i].Hour > stats.Buckets[i - 1].Hour); } } diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs index 354e5a98..3b23160f 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs @@ -53,9 +53,8 @@ public async Task SetJobState_UpdatesExisting() var state = CreateJobState(); await store.SetJobStateAsync(state, cancellationToken: CT); - state.Status = QueueJobStatus.Processing; - state.Progress = 50; - await store.SetJobStateAsync(state, cancellationToken: CT); + var updated = state with { Status = QueueJobStatus.Processing, Progress = 50 }; + await store.SetJobStateAsync(updated, cancellationToken: CT); var retrieved = await store.GetJobStateAsync("job-1", CT); Assert.NotNull(retrieved); @@ -64,22 +63,22 @@ public async Task SetJobState_UpdatesExisting() } [Fact] - public async Task GetJobsByQueue_ReturnsMatchingJobs() + public async Task GetJobsByStatus_ReturnsMatchingJobs() { var store = CreateStore(); await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), cancellationToken: CT); await store.SetJobStateAsync(CreateJobState("job-2", "QueueA"), cancellationToken: CT); await store.SetJobStateAsync(CreateJobState("job-3", "QueueB"), cancellationToken: CT); - var jobsA = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + var jobsA = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); Assert.Equal(2, jobsA.Count); - var jobsB = await store.GetJobsByQueueAsync("QueueB", cancellationToken: CT); + var jobsB = await store.GetJobsByStatusAsync("QueueB", QueueJobStatus.Queued, cancellationToken: CT); Assert.Single(jobsB); } [Fact] - public async Task GetJobsByQueue_OrdersByCreatedUtcDescending() + public async Task GetJobsByStatus_OrdersByCreatedUtcDescending() { var store = CreateStore(); var state1 = CreateJobState("job-1", "QueueA"); @@ -89,14 +88,14 @@ public async Task GetJobsByQueue_OrdersByCreatedUtcDescending() var state2 = CreateJobState("job-2", "QueueA"); await store.SetJobStateAsync(state2, cancellationToken: CT); - var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + var jobs = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); Assert.Equal(2, jobs.Count); Assert.Equal("job-2", jobs[0].JobId); // newer first Assert.Equal("job-1", jobs[1].JobId); } [Fact] - public async Task GetJobsByQueue_SupportsPagination() + public async Task GetJobsByStatus_SupportsPagination() { var store = CreateStore(); for (int i = 1; i <= 5; i++) @@ -105,13 +104,13 @@ public async Task GetJobsByQueue_SupportsPagination() await store.SetJobStateAsync(CreateJobState($"job-{i}", "QueueA"), cancellationToken: CT); } - var page1 = await store.GetJobsByQueueAsync("QueueA", skip: 0, take: 2, cancellationToken: CT); + var page1 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 0, take: 2, cancellationToken: CT); Assert.Equal(2, page1.Count); - var page2 = await store.GetJobsByQueueAsync("QueueA", skip: 2, take: 2, cancellationToken: CT); + var page2 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 2, take: 2, cancellationToken: CT); Assert.Equal(2, page2.Count); - var page3 = await store.GetJobsByQueueAsync("QueueA", skip: 4, take: 2, cancellationToken: CT); + var page3 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 4, take: 2, cancellationToken: CT); Assert.Single(page3); } @@ -208,7 +207,7 @@ public async Task NonExpiredState_StillReturned() } [Fact] - public async Task ExpiredJobs_ExcludedFromQueueListing() + public async Task ExpiredJobs_ExcludedFromStatusListing() { var store = CreateStore(); await store.SetJobStateAsync(CreateJobState("job-1", "QueueA"), expiry: TimeSpan.FromMinutes(1), cancellationToken: CT); @@ -216,7 +215,7 @@ public async Task ExpiredJobs_ExcludedFromQueueListing() _time.Advance(TimeSpan.FromMinutes(2)); - var jobs = await store.GetJobsByQueueAsync("QueueA", cancellationToken: CT); + var jobs = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); Assert.Single(jobs); Assert.Equal("job-2", jobs[0].JobId); } diff --git a/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs index 4632d919..2831c2e1 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs @@ -124,7 +124,7 @@ public async Task TrackedHandler_CompletedJobHasCorrectState() await Task.Delay(200, cts.Token); // Find the job β€” there should be exactly one - var jobs = await stateStore.GetJobsByQueueAsync("TrackedCommand", cancellationToken: cts.Token); + var jobs = await stateStore.GetJobsByStatusAsync("TrackedCommand", QueueJobStatus.Completed, cancellationToken: cts.Token); Assert.Single(jobs); var state = jobs[0]; @@ -167,7 +167,7 @@ public async Task TrackedHandler_ProgressReporting_UpdatesState() await signal.WaitAsync(timeout: TimeSpan.FromSeconds(10)); await Task.Delay(200, cts.Token); - var jobs = await stateStore.GetJobsByQueueAsync("TrackedLongRunningCommand", cancellationToken: cts.Token); + var jobs = await stateStore.GetJobsByStatusAsync("TrackedLongRunningCommand", QueueJobStatus.Completed, cancellationToken: cts.Token); Assert.Single(jobs); var state = jobs[0]; @@ -209,7 +209,7 @@ public async Task TrackedHandler_Cancellation_SetsStateAndCancelsToken() await Task.Delay(500, cts.Token); // Find the job and request cancellation - var jobs = await stateStore.GetJobsByQueueAsync("TrackedCancellableCommand", cancellationToken: cts.Token); + var jobs = await stateStore.GetJobsByStatusAsync("TrackedCancellableCommand", QueueJobStatus.Processing, cancellationToken: cts.Token); Assert.Single(jobs); var jobId = jobs[0].JobId; From e98f8db919b313d3a28a7c8c4260fc3cbcc6c0e2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 7 Apr 2026 11:54:19 -0500 Subject: [PATCH 10/27] More progress --- .vscode/launch.json | 52 +++--- .../src/Api/Api.csproj | 5 - .../src/Api/AppOptions.cs | 38 ++++ .../src/Api/InfrastructureExtensions.cs | 86 +++++++++ .../src/Api/Program.cs | 147 ++++----------- .../src/Api/Properties/launchSettings.json | 24 ++- .../src/Api/appsettings.Development.json | 3 +- .../src/AppHost/Program.cs | 24 ++- .../Handlers/AuditEventHandler.cs | 2 +- .../Handlers/DemoExportJobHandler.cs | 21 ++- .../Handlers/NotificationEventHandler.cs | 2 +- .../Handlers/QueueDashboardHandler.cs | 59 ++++-- .../Messages/QueueDashboardMessages.cs | 8 +- .../src/ServiceDefaults/Extensions.cs | 6 +- .../src/Web/src/lib/api/queues.ts | 4 +- .../src/Web/src/lib/types/queue.ts | 6 +- .../src/Web/src/routes/queues/+page.svelte | 37 ++-- .../IMediatorBuilder.cs | 31 ++++ .../MediatorExtensions.cs | 10 +- .../Result.Generic.cs | 13 ++ src/Foundatio.Mediator.Abstractions/Result.cs | 26 +++ .../AwsServiceExtensions.cs | 159 ++++++++++++++++ .../AwsTransportOptions.cs | 41 ++++ .../SnsSqsPubSubClient.cs | 160 +++++++++++----- .../SqsQueueClient.cs | 42 ++++- .../SqsServiceExtensions.cs | 82 -------- .../RedisJobStateStoreOptions.cs | 11 ++ .../RedisQueueJobStateStore.cs | 19 +- .../RedisServiceExtensions.cs | 19 +- .../DistributedInfrastructureInitializer.cs | 104 +++++++++-- .../DistributedNotificationOptions.cs | 17 ++ .../DistributedNotificationWorker.cs | 16 +- .../DistributedQueueOptions.cs | 45 +++++ .../DistributedServiceExtensions.cs | 114 +++++++----- .../Handlers/QueueDashboardHandler.cs | 154 --------------- .../IQueueJobStateStore.cs | 3 +- .../InMemoryQueueJobStateStore.cs | 3 +- .../Messages/QueueDashboardMessages.cs | 73 -------- .../QueueAttribute.cs | 39 ++-- .../QueueContext.cs | 91 ++++++++- .../QueueJobState.cs | 6 + .../QueueMiddleware.cs | 41 +++- .../QueueWorker.cs | 175 +++++++++++++++--- .../QueueWorkerInfo.cs | 16 +- .../QueueWorkerOptions.cs | 14 +- src/Foundatio.Mediator/EndpointGenerator.cs | 2 +- src/Foundatio.Mediator/HandlerGenerator.cs | 24 ++- ...DistributedNotificationIntegrationTests.cs | 36 ++-- .../QueueWorkerIntegrationTests.cs | 48 ++--- .../QueueWorkerJobTrackingTests.cs | 32 ++-- ...ationTests.EndpointGeneration.verified.txt | 4 +- .../Integration/LoggingIntegrationTests.cs | 16 +- 52 files changed, 1437 insertions(+), 773 deletions(-) create mode 100644 samples/CleanArchitectureSample/src/Api/AppOptions.cs create mode 100644 samples/CleanArchitectureSample/src/Api/InfrastructureExtensions.cs create mode 100644 src/Foundatio.Mediator.Abstractions/IMediatorBuilder.cs create mode 100644 src/Foundatio.Mediator.Distributed.Aws/AwsServiceExtensions.cs create mode 100644 src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs delete mode 100644 src/Foundatio.Mediator.Distributed.Aws/SqsServiceExtensions.cs delete mode 100644 src/Foundatio.Mediator.Distributed/Handlers/QueueDashboardHandler.cs delete mode 100644 src/Foundatio.Mediator.Distributed/Messages/QueueDashboardMessages.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index ebeb61cb..29fd9f9a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,26 +1,28 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Clean Architecture Sample", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/samples/CleanArchitectureSample/src/Api/Api.csproj" - }, - { - "name": "Clean Architecture Sample (Distributed)", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj" - }, - { - "name": "Console Sample", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/samples/ConsoleSample/ConsoleSample.csproj" - } - ] -} + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "aspire", + "request": "launch", + "name": "Clean Architecture Sample", + "program": "${workspaceFolder}/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj", + "debuggers2": { + "project": { + "console": "integratedTerminal", + "logging": { + "moduleLoad": false + } + } + } + }, + { + "name": "Console Sample", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/samples/ConsoleSample/ConsoleSample.csproj" + } + ] +} \ No newline at end of file diff --git a/samples/CleanArchitectureSample/src/Api/Api.csproj b/samples/CleanArchitectureSample/src/Api/Api.csproj index efdbf32e..a3f98538 100644 --- a/samples/CleanArchitectureSample/src/Api/Api.csproj +++ b/samples/CleanArchitectureSample/src/Api/Api.csproj @@ -9,10 +9,6 @@ true Generated $(InterceptorsNamespaces);Foundatio.Mediator - - ..\Web - npm run dev - https://localhost:5173 @@ -44,7 +40,6 @@ - diff --git a/samples/CleanArchitectureSample/src/Api/AppOptions.cs b/samples/CleanArchitectureSample/src/Api/AppOptions.cs new file mode 100644 index 00000000..a845fb59 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Api/AppOptions.cs @@ -0,0 +1,38 @@ +/// +/// Parsed command-line options for the Api host. +/// +/// dotnet run β†’ full app (API + all workers) +/// dotnet run -- --mode api β†’ API-only (no queue workers) +/// dotnet run -- --mode worker β†’ worker-only (all queues) +/// dotnet run -- --mode worker --queues exports β†’ worker-only (specific queues) +/// +/// +public sealed class AppOptions +{ + /// The running mode: "api", "worker", or "both" (default). + public string Mode { get; private init; } = "both"; + + /// When non-empty, only these queue groups will have workers started. + public HashSet? Queues { get; private init; } + + public bool IsApiEnabled => Mode is "api" or "both"; + public bool IsWorkerEnabled => Mode is "worker" or "both"; + + public static AppOptions Parse(string[] args) + { + string mode = "both"; + HashSet? queues = null; + + for (int i = 0; i < args.Length; i++) + { + if (args[i] is "--mode" && i + 1 < args.Length) + mode = args[++i].ToLowerInvariant(); + else if (args[i] is "--queues" && i + 1 < args.Length) + queues = args[++i] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + return new AppOptions { Mode = mode, Queues = queues }; + } +} diff --git a/samples/CleanArchitectureSample/src/Api/InfrastructureExtensions.cs b/samples/CleanArchitectureSample/src/Api/InfrastructureExtensions.cs new file mode 100644 index 00000000..9c7e6a00 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Api/InfrastructureExtensions.cs @@ -0,0 +1,86 @@ +using Foundatio.Mediator.Distributed; +using Microsoft.AspNetCore.Authentication.Cookies; +using StackExchange.Redis; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Infrastructure extension methods for the Api host. +/// +public static class InfrastructureExtensions +{ + /// + /// Registers Redis (, distributed cache) and HybridCache. + /// + public static WebApplicationBuilder AddRedisAndCaching(this WebApplicationBuilder builder) + { + var redisConnection = builder.Configuration.GetConnectionString("redis") + ?? throw new InvalidOperationException( + "A 'redis' connection string is required. Set ConnectionStrings__redis or provide via Aspire."); + + builder.Services.AddSingleton( + ConnectionMultiplexer.Connect(redisConnection)); + + builder.Services.AddStackExchangeRedisCache(options => + options.Configuration = redisConnection); + + builder.Services.AddHybridCache(); + + return builder; + } + + /// + /// Adds cookie authentication configured for API usage (returns 401/403 JSON instead of redirects). + /// + public static WebApplicationBuilder AddSampleAuthentication(this WebApplicationBuilder builder) + { + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.Name = "ModularMonolith.Auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + options.Events.OnRedirectToLogin = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + }; + }); + builder.Services.AddAuthorization(); + + return builder; + } + + /// + /// Logs worker/queue information at startup for dashboard visibility. + /// + public static WebApplication LogStartupDiagnostics(this WebApplication app, AppOptions options) + { + var logger = app.Services.GetRequiredService().CreateLogger("Startup"); + + logger.LogInformation("Running in {Mode} mode (queues: {Queues})", + options.Mode, + options.Queues is { Count: > 0 } ? string.Join(", ", options.Queues) : "all"); + + var workerRegistry = app.Services.GetService(); + if (workerRegistry is not null) + { + var allWorkers = workerRegistry.GetWorkers(); + var activeWorkers = allWorkers.Where(w => w.WorkerRegistered).ToList(); + logger.LogInformation("Queue workers: {ActiveCount}/{TotalCount} registered ({QueueNames})", + activeWorkers.Count, allWorkers.Count, + activeWorkers.Count > 0 + ? string.Join(", ", activeWorkers.Select(w => w.QueueName)) + : "none"); + } + + return app; + } +} diff --git a/samples/CleanArchitectureSample/src/Api/Program.cs b/samples/CleanArchitectureSample/src/Api/Program.cs index f28ba67c..8009db13 100644 --- a/samples/CleanArchitectureSample/src/Api/Program.cs +++ b/samples/CleanArchitectureSample/src/Api/Program.cs @@ -2,136 +2,63 @@ using Foundatio.Mediator; using Foundatio.Mediator.Distributed; using Foundatio.Mediator.Distributed.Aws; -using Microsoft.AspNetCore.Authentication.Cookies; +using Foundatio.Mediator.Distributed.Redis; using Orders.Module; using Products.Module; using Reports.Module; -using Foundatio.Mediator.Distributed.Redis; using Scalar.AspNetCore; -using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); -// Aspire service defaults (OpenTelemetry, health checks, service discovery) -// Works fine both with and without the Aspire AppHost -builder.AddServiceDefaults(); +var options = AppOptions.Parse(args); -builder.Services.AddHttpContextAccessor(); -builder.Services.AddOpenApi(); +builder.AddServiceDefaults(); +builder.AddRedisAndCaching(); -// Simple cookie authentication for the sample -builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => +// ── Foundatio.Mediator ── +builder.Services.AddMediator() + .AddDistributedQueues(opts => { - options.Cookie.Name = "ModularMonolith.Auth"; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Strict; - options.ExpireTimeSpan = TimeSpan.FromHours(8); - options.SlidingExpiration = true; - // Return 401 JSON instead of redirecting to a login page - options.Events.OnRedirectToLogin = context => - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - }; - options.Events.OnRedirectToAccessDenied = context => - { - context.Response.StatusCode = StatusCodes.Status403Forbidden; - return Task.CompletedTask; - }; - }); -builder.Services.AddAuthorization(); - -// Add Foundatio.Mediator β€” all referenced module assemblies are auto-discovered -builder.Services.AddMediator(); - -// ── Redis + HybridCache (L1 in-memory + L2 Redis distributed cache) ── -var redisConnection = builder.Configuration.GetConnectionString("redis"); -if (!string.IsNullOrEmpty(redisConnection)) -{ - // Register IConnectionMultiplexer for repository persistence - builder.Services.AddSingleton( - ConnectionMultiplexer.Connect(redisConnection)); - - // Register IDistributedCache backed by Redis (L2 for HybridCache) - builder.Services.AddStackExchangeRedisCache(options => - options.Configuration = redisConnection); - - // Use Redis for queue job state tracking (shared across all replicas) - builder.Services.AddMediatorRedisJobStateStore(); -} - -// HybridCache provides L1 (in-memory) + L2 (distributed) caching. -// When Redis is configured, the L2 backs all nodes; without it, L1-only. -builder.Services.AddHybridCache(); - -// ── AWS SQS/SNS (only when running under Aspire with LocalStack or real AWS) ── -var awsServiceUrl = builder.Configuration["AWS:ServiceURL"]; -if (!string.IsNullOrEmpty(awsServiceUrl)) -{ - // These usings are only needed in the SQS codepath - import dynamically - // to keep the standalone path clean - ConfigureAwsDistributed(builder.Services, awsServiceUrl); -} - -// Wire up distributed infrastructure (falls back to in-memory when no SQS/SNS is registered) -builder.Services.AddMediatorDistributed(); -builder.Services.AddMediatorDistributedNotifications(); - -// Add module services -// Order matters: Common.Module provides cross-cutting services that other modules may depend on + opts.WorkersEnabled = options.IsWorkerEnabled; + if (options.Queues is { Count: > 0 }) + opts.Queues = options.Queues; + }) + .AddDistributedNotifications() + .UseAws(aws => aws.ServiceUrl = builder.Configuration["AWS:ServiceURL"]!) + .UseRedisJobState(); + +// ── Domain modules ── builder.Services.AddCommonModule(); builder.Services.AddOrdersModule(); builder.Services.AddProductsModule(); builder.Services.AddReportsModule(); -var app = builder.Build(); - -// Health check endpoints -app.MapDefaultEndpoints(); - -// Serve static files from the SPA -app.UseDefaultFiles(); -app.MapStaticAssets(); - -app.MapOpenApi(); -app.MapScalarApiReference(); - -app.UseHttpsRedirection(); - -app.UseAuthentication(); -app.UseAuthorization(); - -// Map module endpoints - discovers and maps all endpoint modules from referenced assemblies -app.MapMediatorEndpoints(); +if (options.IsApiEnabled) +{ + builder.Services.AddHttpContextAccessor(); + builder.Services.AddOpenApi(); + builder.AddSampleAuthentication(); +} -// SPA fallback - serves index.html for client-side routing -app.MapFallbackToFile("/index.html"); +var app = builder.Build(); -app.Run(); +app.LogStartupDiagnostics(options); +app.MapHealthCheckEndpoints(); -// ── Extracted so AWS SDK types are only referenced when AWS:ServiceURL is set ── -static void ConfigureAwsDistributed(IServiceCollection services, string serviceUrl) +if (options.IsApiEnabled) { - // LocalStack doesn't require real credentials β€” use dummy ones to bypass - // the default credential chain which fails without AWS config/env vars - var credentials = new Amazon.Runtime.BasicAWSCredentials("test", "test"); + app.UseDefaultFiles(); + app.MapStaticAssets(); - var sqsConfig = new Amazon.SQS.AmazonSQSConfig - { - ServiceURL = serviceUrl, - AuthenticationRegion = "us-east-1" - }; - services.AddSingleton(_ => new Amazon.SQS.AmazonSQSClient(credentials, sqsConfig)); + app.MapOpenApi(); + app.MapScalarApiReference(); - var snsConfig = new Amazon.SimpleNotificationService.AmazonSimpleNotificationServiceConfig - { - ServiceURL = serviceUrl, - AuthenticationRegion = "us-east-1" - }; - services.AddSingleton( - _ => new Amazon.SimpleNotificationService.AmazonSimpleNotificationServiceClient(credentials, snsConfig)); + app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); - services.AddMediatorSqs(); - services.AddMediatorSnsSqsPubSub(); + app.MapMediatorEndpoints(); + app.MapFallbackToFile("/index.html"); } + +app.Run(); diff --git a/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json b/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json index 6e539d41..0a5058d3 100644 --- a/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json +++ b/samples/CleanArchitectureSample/src/Api/Properties/launchSettings.json @@ -1,15 +1,27 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { - "Web": { + "Api": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "https://localhost:5173", + "commandLineArgs": "--mode api", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" - }, - "applicationUrl": "https://localhost:5099;http://localhost:5098" + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Worker": { + "commandName": "Project", + "commandLineArgs": "--mode worker", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Worker-Exports": { + "commandName": "Project", + "commandLineArgs": "--mode worker --queues exports", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } } } } diff --git a/samples/CleanArchitectureSample/src/Api/appsettings.Development.json b/samples/CleanArchitectureSample/src/Api/appsettings.Development.json index 0c208ae9..62509134 100644 --- a/samples/CleanArchitectureSample/src/Api/appsettings.Development.json +++ b/samples/CleanArchitectureSample/src/Api/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Foundatio.Mediator.Distributed.Aws": "Debug" } } } diff --git a/samples/CleanArchitectureSample/src/AppHost/Program.cs b/samples/CleanArchitectureSample/src/AppHost/Program.cs index 961b658f..93d9bacd 100644 --- a/samples/CleanArchitectureSample/src/AppHost/Program.cs +++ b/samples/CleanArchitectureSample/src/AppHost/Program.cs @@ -9,9 +9,11 @@ // Redis for shared persistence and distributed caching var redis = builder.AddRedis("redis"); -// The API project with 3 replicas to demonstrate distributed pub/sub fan-out +// API project β€” serves HTTP endpoints and the SPA frontend, but no queue workers. +// Queue messages are still enqueued to SQS; the worker resource below processes them. var api = builder.AddProject("api") - // Expose dynamic API endpoints externally so dashboard links and references resolve correctly. + .WithHttpEndpoint() + .WithHttpsEndpoint() .WithExternalHttpEndpoints() .WithReplicas(3) .WaitFor(localstack) @@ -19,8 +21,22 @@ .WithReference(localstack.GetEndpoint("main")) .WithReference(redis) .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("main")) - // Disable per-replica SpaProxy startup; AppHost owns a single frontend process. - .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", string.Empty); + // API-only mode β€” no queue workers in this process + .WithArgs("--mode", "api"); + +// Worker project β€” processes all queues, exposes only health checks (no API/UI). +// Runs the same Api project in worker mode so it shares handler code and module registrations. +builder.AddProject("worker") + .WithHttpEndpoint() + .WithHttpsEndpoint() + .WithReplicas(3) + .WaitFor(localstack) + .WaitFor(redis) + .WithReference(localstack.GetEndpoint("main")) + .WithReference(redis) + .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("main")) + // Worker mode β€” health checks only, all queue workers active + .WithArgs("--mode", "worker"); // Run a single Vite frontend for all API replicas in distributed mode. builder.AddViteApp("web", "../Web") diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs index 3edc2783..ff0e8de2 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs @@ -15,7 +15,7 @@ namespace Common.Module.Handlers; /// Decorated with [Queue] so audit logging is processed asynchronously via SQS, /// keeping the request path fast. /// -[Queue] +[Queue(Group = "events", Description = "Logs audit trail entries for tracked operations")] public class AuditEventHandler(IAuditService auditService, ILogger logger) { // Order events diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs index 1c18c145..48ef90d2 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs @@ -10,13 +10,14 @@ namespace Common.Module.Handlers; /// Simulates a long-running export/report generation job that reports progress /// and supports cancellation via the queue job state store. /// -[Queue(TrackProgress = true, Concurrency = 5)] +[Queue(TrackProgress = true, Concurrency = 5, TimeoutSeconds = 10, Group = "exports", Description = "Processes export jobs with progress tracking")] public class DemoExportJobHandler(ILogger logger) { public async Task HandleAsync(DemoExportJob message, QueueContext queueContext, CancellationToken ct) { - // Add per-job variability: Β±40% on step count, Β±50% on delay var rng = Random.Shared; + + // Add per-job variability: Β±40% on step count, Β±50% on delay int steps = Math.Max(3, (int)(message.Steps * (0.6 + rng.NextDouble() * 0.8))); int baseDelay = Math.Max(100, (int)(message.StepDelayMs * (0.5 + rng.NextDouble()))); @@ -26,6 +27,22 @@ public async Task HandleAsync(DemoExportJob message, QueueContext queueC { ct.ThrowIfCancellationRequested(); + // ~5% chance of a transient error (e.g. network blip, temporary service outage). + // Returning Result.Error tells the QueueWorker to abandon the message so it can be retried. + if (rng.NextDouble() < 0.05) + { + logger.LogWarning("Demo export: simulated transient error on step {Step}", i); + return Result.Error($"Transient failure on step {i} β€” will be retried"); + } + + // ~1% chance of an unrecoverable error (e.g. corrupt data, invalid configuration). + // Returning Result.CriticalError tells the QueueWorker to dead-letter the message immediately. + if (rng.NextDouble() < 0.01) + { + logger.LogError("Demo export: simulated critical error on step {Step}", i); + return Result.CriticalError($"Unrecoverable failure on step {i} β€” will not be retried"); + } + // Simulate variable work β€” some steps are fast, some slow int jitter = (int)(baseDelay * (0.3 + rng.NextDouble() * 1.4)); await Task.Delay(jitter, ct).ConfigureAwait(false); diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs index bfb45a10..220daef7 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/NotificationEventHandler.cs @@ -16,7 +16,7 @@ namespace Common.Module.Handlers; /// Decorated with [Queue] so notification delivery is processed asynchronously /// via SQS, keeping the request path fast. /// -[Queue] +[Queue(Group = "events", Description = "Sends notifications for domain events")] public class NotificationEventHandler(INotificationService notificationService, ILogger logger) { private const int LowStockThreshold = 10; diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs index 09ff107a..e6254502 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs @@ -1,4 +1,5 @@ using Common.Module.Messages; +using Common.Module.Middleware; using Foundatio.Mediator; using Foundatio.Mediator.Distributed; @@ -7,6 +8,10 @@ namespace Common.Module.Handlers; /// /// Queue dashboard handler β€” exposes queue workers, job tracking, and cancellation /// as mediator endpoints under /api/queues. +/// +/// Uses [Cached] on read-heavy endpoints so multiple browser tabs or +/// overlapping poll intervals share a single SQS/Redis call instead of each +/// hitting the transport independently. /// [HandlerEndpointGroup("Queues")] [HandlerAllowAnonymous] @@ -15,42 +20,45 @@ public class QueueDashboardHandler private readonly IQueueWorkerRegistry _registry; private readonly IQueueClient _queueClient; private readonly IQueueJobStateStore? _stateStore; + private readonly DistributedInfrastructureReady? _infraReady; - public QueueDashboardHandler(IQueueWorkerRegistry registry, IQueueClient queueClient, IQueueJobStateStore? stateStore = null) + public QueueDashboardHandler( + IQueueWorkerRegistry registry, + IQueueClient queueClient, + IQueueJobStateStore? stateStore = null, + DistributedInfrastructureReady? infraReady = null) { _registry = registry; _queueClient = queueClient; _stateStore = stateStore; + _infraReady = infraReady; } + [Cached(DurationSeconds = 2)] public async Task>> HandleAsync(GetQueues query, CancellationToken ct) { var workers = _registry.GetWorkers(); - var results = new List(workers.Count); - foreach (var worker in workers) + // Fetch all queue stats in parallel β€” each is an independent SQS call. + var tasks = new Task[workers.Count]; + for (int i = 0; i < workers.Count; i++) { - QueueStats? stats = null; - try { stats = await _queueClient.GetQueueStatsAsync(worker.QueueName, ct).ConfigureAwait(false); } - catch { /* Transport may not support stats */ } - - results.Add(await ToSummaryAsync(worker, stats, ct).ConfigureAwait(false)); + var worker = workers[i]; + tasks[i] = BuildSummaryAsync(worker, ct); } - return results; + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + return results.ToList(); } + [Cached(DurationSeconds = 2)] public async Task> HandleAsync(GetQueue query, CancellationToken ct) { var worker = _registry.GetWorker(query.QueueName); if (worker is null) return Result.NotFound($"Queue worker '{query.QueueName}' not found"); - QueueStats? stats = null; - try { stats = await _queueClient.GetQueueStatsAsync(query.QueueName, ct).ConfigureAwait(false); } - catch { /* Transport may not support stats */ } - - return await ToSummaryAsync(worker, stats, ct).ConfigureAwait(false); + return await BuildSummaryAsync(worker, ct).ConfigureAwait(false); } public async Task> HandleAsync(GetJobDashboard query, CancellationToken ct) @@ -137,6 +145,21 @@ public async Task> HandleAsync(EnqueueDemoJob command, I return new DemoJobEnqueued(lastJobId ?? string.Empty); } + private async Task BuildSummaryAsync(QueueWorkerInfo worker, CancellationToken ct) + { + // Wait for queues to be created before hitting SQS for stats. + // Without this, cold-start dashboard requests trigger slow/failing + // GetQueueAttributes calls for queues that don't exist yet. + if (_infraReady is not null) + await _infraReady.WaitAsync(ct).ConfigureAwait(false); + + QueueStats? stats = null; + try { stats = await _queueClient.GetQueueStatsAsync(worker.QueueName, ct).ConfigureAwait(false); } + catch { /* Transport may not support stats */ } + + return await ToSummaryAsync(worker, stats, ct).ConfigureAwait(false); + } + private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueStats? stats, CancellationToken ct) { QueueCounterStats? counterStats = null; @@ -172,15 +195,16 @@ private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueSta QueueName = worker.QueueName, MessageType = worker.MessageTypeName, Concurrency = worker.Concurrency, - MaxRetries = worker.MaxRetries, + MaxAttempts = worker.MaxAttempts, RetryPolicy = worker.RetryPolicy.ToString(), TrackProgress = worker.TrackProgress, - IsRunning = worker.IsRunning, + Description = worker.Description, + IsRunning = worker.WorkerRegistered ? worker.IsRunning : null, MessagesProcessed = counterStats?.Totals.GetValueOrDefault("processed") ?? worker.MessagesProcessed, MessagesFailed = counterStats?.Totals.GetValueOrDefault("failed") ?? worker.MessagesFailed, MessagesDeadLettered = counterStats?.Totals.GetValueOrDefault("dead_lettered") ?? worker.MessagesDeadLettered, ActiveCount = stats?.ActiveCount ?? 0, - DeadLetterCount = stats?.DeadLetterCount ?? 0, + DeadLetterCount = counterStats?.Totals.GetValueOrDefault("dead_lettered") ?? stats?.DeadLetterCount ?? 0, InFlightCount = processingCount ?? stats?.InFlightCount ?? 0, CounterStats = counterStatsView }; @@ -194,6 +218,7 @@ private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueSta Status = s.Status.ToString(), Progress = s.Progress, ProgressMessage = s.ProgressMessage, + Attempt = s.Attempt, CreatedUtc = s.CreatedUtc, StartedUtc = s.StartedUtc, CompletedUtc = s.CompletedUtc, diff --git a/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs b/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs index cb1a5142..1b71ee58 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs @@ -6,7 +6,7 @@ public record GetQueues; public record GetQueue(string QueueName); -public record GetJobDashboard(string QueueName, int? RecentTerminalCount = 20); +public record GetJobDashboard(string QueueName, int? RecentTerminalCount = 5); public record GetQueueJobDetail(string JobId); @@ -21,10 +21,11 @@ public record QueueSummary public required string QueueName { get; init; } public required string MessageType { get; init; } public int Concurrency { get; init; } - public int MaxRetries { get; init; } + public int MaxAttempts { get; init; } public required string RetryPolicy { get; init; } public bool TrackProgress { get; init; } - public bool IsRunning { get; init; } + public string? Description { get; init; } + public bool? IsRunning { get; init; } public long MessagesProcessed { get; init; } public long MessagesFailed { get; init; } public long MessagesDeadLettered { get; init; } @@ -42,6 +43,7 @@ public record JobSummary public required string Status { get; init; } public int Progress { get; init; } public string? ProgressMessage { get; init; } + public int Attempt { get; init; } public DateTimeOffset CreatedUtc { get; init; } public DateTimeOffset? StartedUtc { get; init; } public DateTimeOffset? CompletedUtc { get; init; } diff --git a/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs b/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs index 5d1c5eb2..261c1eae 100644 --- a/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs @@ -45,7 +45,9 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w { tracing.AddAspNetCoreInstrumentation(o => { - o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/api/events"); + o.Filter = ctx => + !ctx.Request.Path.StartsWithSegments("/api/events") + && !ctx.Request.Path.StartsWithSegments("/api/queues"); }) .AddHttpClientInstrumentation(o => { @@ -90,7 +92,7 @@ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) w return builder; } - public static WebApplication MapDefaultEndpoints(this WebApplication app) + public static WebApplication MapHealthCheckEndpoints(this WebApplication app) { app.MapHealthChecks("/health"); app.MapHealthChecks("/alive", new HealthCheckOptions diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts b/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts index 13d76ca5..a7be4867 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts @@ -7,9 +7,9 @@ export const queuesApi = { getWorker: (queueName: string) => api.getJSON(`/api/queues/queue?queueName=${encodeURIComponent(queueName)}`), - getJobDashboard: (queueName: string) => + getJobDashboard: (queueName: string, recentTerminalCount: number = 5) => api.getJSON( - `/api/queues/job-dashboard?queueName=${encodeURIComponent(queueName)}` + `/api/queues/job-dashboard?queueName=${encodeURIComponent(queueName)}&recentTerminalCount=${recentTerminalCount}` ), getJob: (jobId: string) => api.getJSON(`/api/queues/queue-job/${jobId}`), diff --git a/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts b/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts index 787928d5..0dc44ac5 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts @@ -2,10 +2,11 @@ export interface QueueSummary { queueName: string; messageType: string; concurrency: number; - maxRetries: number; + maxAttempts: number; retryPolicy: string; trackProgress: boolean; - isRunning: boolean; + description: string | null; + isRunning: boolean | null; messagesProcessed: number; messagesFailed: number; messagesDeadLettered: number; @@ -24,6 +25,7 @@ export interface JobSummary { status: JobStatus; progress: number; progressMessage: string | null; + attempt: number; createdUtc: string; startedUtc: string | null; completedUtc: string | null; diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte index 7c42d3ef..86103421 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte @@ -65,7 +65,7 @@ if (result.data) dashboard = result.data; } catch { /* ignore */ } } - }, 1000); + }, 2000); } function stopJobPolling() { @@ -188,7 +188,6 @@
- @@ -204,31 +203,13 @@ onclick={() => selectQueue(worker.queueName)} > - @@ -248,7 +229,7 @@

{selectedQueue}

- Retry: {queueWorker?.retryPolicy} Β· Max retries: {queueWorker?.maxRetries} + Retry: {queueWorker?.retryPolicy} Β· Max attempts: {queueWorker?.maxAttempts}

- @@ -115,20 +65,20 @@ bind:this={listEl} class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden" > - {#if events.length === 0} + {#if eventStream.events.length === 0}

Waiting for events…

Create, update, or delete orders and products to see live events here.

- {#if paused} + {#if eventStream.paused}

Event capture is paused

{/if}
{:else}
- {#each events as event (event.id)} + {#each eventStream.events as event (event.id)}
{formatTime(event.timestamp)} @@ -147,7 +97,7 @@ {/if}
- {#if paused} + {#if eventStream.paused}
diff --git a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs index b8bcf6a5..32f7dfc8 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs @@ -76,8 +76,8 @@ public async Task SubscribeAsync(string topic, Func.Instance; } + /// + /// SQS allows a maximum of 10 message attributes per message. + /// + private const int MaxSqsMessageAttributes = 10; + public async Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default) { var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); + ValidateHeaderCount(entry.Headers, queueName); + var request = new SendMessageRequest { QueueUrl = queueUrl, @@ -74,6 +81,9 @@ public async Task SendBatchAsync(string queueName, IReadOnlyList ent for (int j = i; j < end; j++) { var entry = entries[j]; + + ValidateHeaderCount(entry.Headers, queueName); + var batchEntry = new SendMessageBatchRequestEntry { Id = j.ToString(CultureInfo.InvariantCulture), @@ -362,4 +372,11 @@ private static Message GetNativeMessage(QueueMessage message) => message.NativeMessage as Message ?? throw new InvalidOperationException( "QueueMessage.NativeMessage is not an SQS Message. This QueueMessage was not created by SqsQueueClient."); + + private static void ValidateHeaderCount(Dictionary? headers, string queueName) + { + if (headers is { Count: > MaxSqsMessageAttributes }) + throw new InvalidOperationException( + $"Message for queue '{queueName}' has {headers.Count} headers, but SQS allows a maximum of {MaxSqsMessageAttributes} message attributes."); + } } diff --git a/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs index 2ff9ced3..aa69dc1c 100644 --- a/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs @@ -12,12 +12,14 @@ public sealed class RedisQueueJobStateStore : IQueueJobStateStore { private readonly IConnectionMultiplexer _redis; private readonly RedisJobStateStoreOptions _options; + private readonly TimeProvider _timeProvider; private readonly string _keyPrefix; - public RedisQueueJobStateStore(IConnectionMultiplexer redis, RedisJobStateStoreOptions? options = null) + public RedisQueueJobStateStore(IConnectionMultiplexer redis, RedisJobStateStoreOptions? options = null, TimeProvider? timeProvider = null) { _redis = redis; _options = options ?? new RedisJobStateStoreOptions(); + _timeProvider = timeProvider ?? TimeProvider.System; _keyPrefix = string.IsNullOrEmpty(_options.ResourcePrefix) ? _options.KeyPrefix : $"{_options.ResourcePrefix}:{_options.KeyPrefix}"; @@ -90,19 +92,19 @@ public async Task UpdateJobStatusAsync(string jobId, QueueJobStatus status, Date { var db = _redis.GetDatabase(); var key = JobKey(jobId); + var now = _timeProvider.GetUtcNow(); - // Read old status + queue name for sorted set migration - var fields = await db.HashGetAsync(key, ["Status", "QueueName", "CreatedUtc"]).ConfigureAwait(false); + // Read queue name, created score, and current status for sorted set migration. + var fields = await db.HashGetAsync(key, ["QueueName", "CreatedUtc", "Status"]).ConfigureAwait(false); if (fields[0].IsNullOrEmpty) return; // Job doesn't exist - var queueName = fields[1].ToString(); - var createdScore = long.TryParse(fields[2].ToString(), out var cs) ? cs : 0d; - var oldStatusInt = int.TryParse(fields[0].ToString(), out var osi) ? osi : -1; + var queueName = fields[0].ToString(); + var createdScore = long.TryParse(fields[1].ToString(), out var cs) ? cs : 0L; + var oldStatusInt = int.TryParse(fields[2].ToString(), out var osi) ? osi : -1; var oldStatus = (QueueJobStatus)oldStatusInt; - var now = DateTimeOffset.UtcNow; - // Build only the fields that need updating + // Build hash field updates var updates = new List { new("Status", ((int)status).ToString(CultureInfo.InvariantCulture)), @@ -110,32 +112,36 @@ public async Task UpdateJobStatusAsync(string jobId, QueueJobStatus status, Date }; if (startedUtc.HasValue) - updates.Add(new HashEntry("StartedUtc", startedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); + updates.Add(new("StartedUtc", startedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); if (completedUtc.HasValue) - updates.Add(new HashEntry("CompletedUtc", completedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); + updates.Add(new("CompletedUtc", completedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); if (errorMessage is not null) - updates.Add(new HashEntry("ErrorMessage", errorMessage)); + updates.Add(new("ErrorMessage", errorMessage)); if (progress.HasValue) - updates.Add(new HashEntry("Progress", progress.Value.ToString(CultureInfo.InvariantCulture))); + updates.Add(new("Progress", progress.Value.ToString(CultureInfo.InvariantCulture))); if (attempt.HasValue) - updates.Add(new HashEntry("Attempt", attempt.Value.ToString(CultureInfo.InvariantCulture))); + updates.Add(new("Attempt", attempt.Value.ToString(CultureInfo.InvariantCulture))); - await db.HashSetAsync(key, updates.ToArray()).ConfigureAwait(false); + var ttl = expiry ?? _options.DefaultExpiry; + var newStatusSetKey = StatusSetKey(queueName, status); + + // All writes in a single MULTI/EXEC transaction β€” atomic without Lua + var txn = db.CreateTransaction(); + txn.AddCondition(Condition.KeyExists(key)); + + _ = txn.HashSetAsync(key, updates.ToArray()); - // Migrate sorted sets if status changed if (oldStatus != status) - { - await db.SortedSetRemoveAsync(StatusSetKey(queueName, oldStatus), jobId).ConfigureAwait(false); - await db.SortedSetAddAsync(StatusSetKey(queueName, status), jobId, createdScore).ConfigureAwait(false); - } + _ = txn.SortedSetRemoveAsync(StatusSetKey(queueName, oldStatus), jobId); + _ = txn.SortedSetAddAsync(newStatusSetKey, jobId, createdScore); - // Refresh TTL - var ttl = expiry ?? _options.DefaultExpiry; if (ttl.HasValue) { - await db.KeyExpireAsync(key, ttl.Value).ConfigureAwait(false); - await db.KeyExpireAsync(StatusSetKey(queueName, status), ttl.Value).ConfigureAwait(false); + _ = txn.KeyExpireAsync(key, ttl.Value); + _ = txn.KeyExpireAsync(newStatusSetKey, ttl.Value); } + + await txn.ExecuteAsync().ConfigureAwait(false); } public async Task UpdateJobProgressAsync(string jobId, int progress, string? progressMessage = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) @@ -146,7 +152,7 @@ public async Task UpdateJobProgressAsync(string jobId, int progress, string? pro if (!await db.KeyExistsAsync(key).ConfigureAwait(false)) return; - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var updates = new HashEntry[] { new("Progress", progress.ToString(CultureInfo.InvariantCulture)), @@ -223,7 +229,7 @@ public async Task RemoveJobStateAsync(string jobId, CancellationToken cancellati public Task IncrementCounterAsync(string queueName, string counterName, long value = 1, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); - var bucketKey = CounterBucketKey(queueName, DateTimeOffset.UtcNow); + var bucketKey = CounterBucketKey(queueName, _timeProvider.GetUtcNow()); var task = db.HashIncrementAsync(bucketKey, counterName, value); // Auto-expire each hourly bucket after 48h so old buckets clean themselves up @@ -235,7 +241,7 @@ public Task IncrementCounterAsync(string queueName, string counterName, long val public async Task GetCounterStatsAsync(string queueName, TimeSpan? window = null, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var effectiveWindow = window ?? TimeSpan.FromHours(24); var startHour = TruncateToHour(now - effectiveWindow); var endHour = TruncateToHour(now); diff --git a/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs b/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs index 0e64bd4a..dc8e1a29 100644 --- a/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs @@ -35,7 +35,8 @@ public static IMediatorBuilder UseRedisJobState( services.AddSingleton(sp => new RedisQueueJobStateStore( sp.GetRequiredService(), - sp.GetService())); + sp.GetService(), + sp.GetService())); return builder; } diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs index 445d2e4c..af8c9c7f 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Reflection; using System.Text.Json; using System.Threading.Channels; @@ -83,6 +84,7 @@ public class DistributedNotificationOptions public DistributedNotificationOptions Include() { IncludedTypes.Add(typeof(T)); + _shouldDistributeCache.TryRemove(typeof(T), out _); return this; } @@ -94,6 +96,7 @@ public DistributedNotificationOptions Include() public DistributedNotificationOptions Include(Type type) { IncludedTypes.Add(type); + _shouldDistributeCache.TryRemove(type, out _); return this; } @@ -109,7 +112,10 @@ public DistributedNotificationOptions IncludeNotificationsFromAssemblyOf() foreach (var type in typeof(T).Assembly.GetExportedTypes()) { if (typeof(INotification).IsAssignableFrom(type) && type is { IsAbstract: false, IsInterface: false }) + { IncludedTypes.Add(type); + _shouldDistributeCache.TryRemove(type, out _); + } } return this; @@ -120,27 +126,35 @@ public DistributedNotificationOptions IncludeNotificationsFromAssemblyOf() /// internal HashSet IncludedTypes { get; } = []; + private readonly ConcurrentDictionary _shouldDistributeCache = new(); + /// /// Determines whether a given message type should be distributed via pub/sub. /// Evaluation order: explicit includes β†’ β†’ /// β†’ β†’ /// . /// + /// + /// Results are cached per type to avoid repeated reflection on the hot path. + /// public bool ShouldDistribute(Type messageType) { - if (IncludedTypes.Contains(messageType)) - return true; + return _shouldDistributeCache.GetOrAdd(messageType, static (type, self) => + { + if (self.IncludedTypes.Contains(type)) + return true; - if (typeof(IDistributedNotification).IsAssignableFrom(messageType)) - return true; + if (typeof(IDistributedNotification).IsAssignableFrom(type)) + return true; - if (messageType.GetCustomAttribute() is not null) - return true; + if (type.GetCustomAttribute() is not null) + return true; - if (MessageFilter is not null) - return MessageFilter(messageType); + if (self.MessageFilter is not null) + return self.MessageFilter(type); - return IncludeAllNotifications; + return self.IncludeAllNotifications; + }, this); } /// diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs index 3952b5be..bed6018b 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs @@ -41,6 +41,12 @@ public sealed class DistributedNotificationWorker : BackgroundService /// private readonly ConcurrentDictionary _inboundMessages = new(ReferenceEqualityComparer.Instance); + /// + /// Safety cap for . Under normal operation the outbound + /// loop removes entries quickly, but if it stalls this prevents unbounded memory growth. + /// + private const int MaxInboundTrackingEntries = 10_000; + private readonly DistributedInfrastructureReady? _infraReady; private readonly TimeProvider _timeProvider; @@ -122,7 +128,7 @@ private async Task RunOutboundLoopAsync(CancellationToken stoppingToken) var headers = new Dictionary { - [MessageHeaders.MessageType] = messageType.AssemblyQualifiedName!, + [MessageHeaders.MessageType] = messageType.FullName!, [MessageHeaders.OriginHostId] = _options.HostId, [MessageHeaders.PublishedAt] = _timeProvider.GetUtcNow().ToString("O") }; @@ -231,6 +237,15 @@ private async Task ProcessInboundMessageAsync(PubSubMessage message, Cancellatio // Mark by reference so the outbound loop skips this message. // Removal happens in the outbound loop (TryRemove) to avoid a race where this // finally block runs before the outbound loop reads from the channel. + if (_inboundMessages.Count >= MaxInboundTrackingEntries) + { + _logger.LogWarning( + "Inbound message tracking dictionary exceeded {MaxEntries} entries β€” clearing to prevent unbounded growth. " + + "This may briefly allow a re-broadcast of an in-flight notification.", + MaxInboundTrackingEntries); + _inboundMessages.Clear(); + } + _inboundMessages.TryAdd(notification, 0); bool published = false; try diff --git a/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs b/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs index 09aeb02c..cab33247 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs @@ -61,6 +61,6 @@ public class DistributedQueueOptions /// Applies to the given queue name. /// Returns the name unchanged when no prefix is configured. /// - internal string ApplyPrefix(string name) => + public string ApplyPrefix(string name) => string.IsNullOrEmpty(ResourcePrefix) ? name : $"{ResourcePrefix}-{name}"; } diff --git a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs index 4a9e6596..ceb67a49 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs @@ -168,7 +168,7 @@ public static IMediatorBuilder AddDistributedQueues( continue; // Mark that a worker is actually running on this node - workerInfo.WorkerRegistered = true; + workerInfo.Stats.SetWorkerRegistered(true); // Register as a hosted service using a factory so each worker gets its own options services.AddSingleton(sp => new QueueWorker( @@ -244,9 +244,9 @@ public static IMediatorBuilder AddDistributedNotifications( // Register known notification types in the type resolver var registry = services.GetHandlerRegistry(); + var typeResolver = GetOrAddTypeResolver(services); if (registry is not null) { - var typeResolver = GetOrAddTypeResolver(services); foreach (var reg in registry.Registrations) { if (reg.MessageType is not null && options.ShouldDistribute(reg.MessageType)) @@ -268,8 +268,7 @@ public static IMediatorBuilder AddDistributedNotifications( foreach (var type in options.IncludedTypes) { - var typeResolver2 = GetOrAddTypeResolver(services); - typeResolver2.Register(type); + typeResolver.Register(type); distributedTypes.Add(type); } diff --git a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs index 878b6385..d1ba6417 100644 --- a/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs @@ -31,8 +31,7 @@ public interface IQueueJobStateStore /// The processing attempt number (1 = first try, 2+ = retry). /// Optional sliding expiry for the entry. /// Cancellation token. - Task UpdateJobStatusAsync(string jobId, QueueJobStatus status, DateTimeOffset? startedUtc = null, DateTimeOffset? completedUtc = null, string? errorMessage = null, int? progress = null, int? attempt = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default) - => Task.CompletedTask; + Task UpdateJobStatusAsync(string jobId, QueueJobStatus status, DateTimeOffset? startedUtc = null, DateTimeOffset? completedUtc = null, string? errorMessage = null, int? progress = null, int? attempt = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default); /// /// Atomically updates job progress and optional message without requiring a prior read. diff --git a/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs index 8f761d08..54fbf733 100644 --- a/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs @@ -11,6 +11,7 @@ namespace Foundatio.Mediator.Distributed; public sealed class InMemoryPubSubClient : IPubSubClient, IDisposable { private readonly ConcurrentDictionary> _subscriptions = new(); + private readonly ConcurrentBag _activeCts = []; /// public Task PublishAsync(string topic, PubSubEntry entry, CancellationToken cancellationToken = default) @@ -49,13 +50,26 @@ public Task SubscribeAsync(string topic, Func { try { await foreach (var msg in channel.Reader.ReadAllAsync(cts.Token).ConfigureAwait(false)) { - await handler(msg, cts.Token).ConfigureAwait(false); + try + { + await handler(msg, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch + { + // Swallow handler exceptions to keep the subscription alive. + // In-memory client is for dev/testing; production transports log errors. + } } } catch (OperationCanceledException) { } @@ -75,6 +89,12 @@ public Task SubscribeAsync(string topic, Func> ReceiveAsync(string queueName, in } var now = _timeProvider.GetUtcNow(); - first.DequeueCount++; + first.IncrementDequeueCount(); results.Add(ToQueueMessage(first, queueName, now)); // Try to read more without waiting while (results.Count < maxCount && channel.Reader.TryRead(out var entry)) { - entry.DequeueCount++; + entry.IncrementDequeueCount(); results.Add(ToQueueMessage(entry, queueName, now)); } @@ -191,7 +191,9 @@ private sealed class InMemoryEntry public required string Id { get; init; } public required ReadOnlyMemory Body { get; init; } public required Dictionary Headers { get; init; } - public int DequeueCount { get; set; } + private int _dequeueCount; + public int DequeueCount { get => _dequeueCount; set => _dequeueCount = value; } + public int IncrementDequeueCount() => Interlocked.Increment(ref _dequeueCount); public DateTimeOffset EnqueuedAt { get; init; } } } diff --git a/src/Foundatio.Mediator.Distributed/QueueClientBase.cs b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs index d0b4c22e..7615f0f3 100644 --- a/src/Foundatio.Mediator.Distributed/QueueClientBase.cs +++ b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + namespace Foundatio.Mediator.Distributed; /// @@ -19,6 +22,19 @@ namespace Foundatio.Mediator.Distributed; /// public abstract class QueueClientBase : IQueueClient { + /// + /// Logger available for derived classes. Defaults to . + /// + protected ILogger Logger { get; } + + /// + /// Initializes a new instance of . + /// + /// Optional logger for diagnostics. Defaults to . + protected QueueClientBase(ILogger? logger = null) + { + Logger = logger ?? NullLogger.Instance; + } /// public abstract Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default); @@ -55,11 +71,21 @@ public virtual Task RenewTimeoutAsync(QueueMessage message, TimeSpan extension, /// Default implementation sends the original message (with dead-letter metadata headers) /// to {queueName}-dead-letter, then completes the original message. /// Override if the transport has native dead-letter support (e.g., Azure Service Bus). + /// + /// Important: Transport implementations that do not support dead-letter queues + /// should override this method. The default behaviour discards the message after + /// sending it to a DLQ queue name that may not exist for the transport. + /// /// public virtual async Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default) { var dlqName = $"{message.QueueName}-dead-letter"; + Logger.LogWarning( + "Using default DeadLetterAsync for queue {QueueName}: forwarding to {DlqName}. " + + "Override DeadLetterAsync to use transport-native dead-letter support.", + message.QueueName, dlqName); + var headers = new Dictionary(message.Headers) { [MessageHeaders.DeadLetterReason] = reason, diff --git a/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs index 8d7d94ae..0d7b8b8b 100644 --- a/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs +++ b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs @@ -59,12 +59,24 @@ public QueueMiddleware(IQueueClient client, HandlerRegistry registry, Distribute // Enqueue path: serialize and send to the queue var messageType = message.GetType(); - var body = JsonSerializer.SerializeToUtf8Bytes(message, messageType, _jsonOptions); var metadata = GetMetadata(handlerInfo.DescriptorId, messageType); + // Validate that the handler's declared return type is compatible with queue processing. + // Queue handlers can only return void/Task/ValueTask, Result, or Result. + // This must be checked before sending to avoid enqueueing messages for incompatible handlers. + if (!string.IsNullOrEmpty(metadata.ReturnTypeName) + && !metadata.ReturnTypeName.StartsWith("Foundatio.Mediator.Result", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Queue handler '{handlerInfo.DescriptorId}' returns '{metadata.ReturnTypeName}' which is incompatible with queue processing. " + + "Queue handlers must return void, Task, Result, or Result."); + } + + var body = JsonSerializer.SerializeToUtf8Bytes(message, messageType, _jsonOptions); + var headers = new Dictionary { - [MessageHeaders.MessageType] = messageType.AssemblyQualifiedName!, + [MessageHeaders.MessageType] = messageType.FullName!, [MessageHeaders.EnqueuedAt] = _timeProvider.GetUtcNow().ToString("O") }; @@ -106,16 +118,6 @@ public QueueMiddleware(IQueueClient client, HandlerRegistry registry, Distribute await _client.SendAsync(metadata.QueueName, entry, cancellationToken).ConfigureAwait(false); - // Validate that the handler's declared return type is compatible with queue processing. - // Queue handlers can only return void/Task/ValueTask, Result, or Result. - if (!string.IsNullOrEmpty(metadata.ReturnTypeName) - && !metadata.ReturnTypeName.StartsWith("Foundatio.Mediator.Result", StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Queue handler '{handlerInfo.DescriptorId}' returns '{metadata.ReturnTypeName}' which is incompatible with queue processing. " + - "Queue handlers must return void, Task, Result, or Result."); - } - if (jobId is not null) return Result.Accepted("Message queued", jobId); diff --git a/src/Foundatio.Mediator.Distributed/QueueRetryDelay.cs b/src/Foundatio.Mediator.Distributed/QueueRetryDelay.cs new file mode 100644 index 00000000..66ed2e0a --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueRetryDelay.cs @@ -0,0 +1,42 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Computes retry delays for failed queue messages based on a . +/// +public static class QueueRetryDelay +{ + /// + /// Computes the retry delay for a failed message based on the configured retry policy, + /// base delay, and the number of times the message has been dequeued. + /// + /// The retry policy to apply. + /// The base delay duration. + /// The 1-based dequeue count (including the current attempt). + /// The computed delay, capped at 15 minutes. + public static TimeSpan Compute(QueueRetryPolicy policy, TimeSpan baseDelay, int dequeueCount) + { + if (policy == QueueRetryPolicy.None) + return TimeSpan.Zero; + + if (baseDelay <= TimeSpan.Zero) + return TimeSpan.Zero; + + // dequeueCount is 1-based; first retry is after attempt 1 + int retryNumber = Math.Max(0, dequeueCount - 1); + + double delayMs = policy switch + { + QueueRetryPolicy.Fixed => baseDelay.TotalMilliseconds, + QueueRetryPolicy.Exponential => baseDelay.TotalMilliseconds * Math.Pow(2, retryNumber), + _ => 0 + }; + + // Apply proportional jitter (Β±10% of the computed delay) + double jitterRange = delayMs * 0.1; + double jitter = (Random.Shared.NextDouble() * 2 - 1) * jitterRange; + delayMs = Math.Max(0, delayMs + jitter); + + // Cap at 15 minutes to prevent unreasonably long delays + return TimeSpan.FromMilliseconds(Math.Min(delayMs, TimeSpan.FromMinutes(15).TotalMilliseconds)); + } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueWorker.cs b/src/Foundatio.Mediator.Distributed/QueueWorker.cs index 9b01b688..757ba6ad 100644 --- a/src/Foundatio.Mediator.Distributed/QueueWorker.cs +++ b/src/Foundatio.Mediator.Distributed/QueueWorker.cs @@ -57,7 +57,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } } - _workerInfo?.SetRunning(true); + _workerInfo?.Stats.SetRunning(true); try { @@ -86,6 +86,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) finally { channel.Writer.Complete(); + + // Drain any buffered messages that consumers haven't picked up yet + // and abandon them so they become visible for redelivery on other nodes. + while (channel.Reader.TryRead(out var orphan)) + { + try { await _client.AbandonAsync(orphan).ConfigureAwait(false); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to abandon buffered message {MessageId} during shutdown", orphan.Id); } + } + await Task.WhenAll(consumers).ConfigureAwait(false); } @@ -93,7 +102,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } finally { - _workerInfo?.SetRunning(false); + _workerInfo?.Stats.SetRunning(false); } } @@ -165,10 +174,10 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s activity?.SetTag("messaging.dead_letter", true); activity?.SetTag("messaging.dead_letter.reason", "MaxAttemptsExceeded"); - _workerInfo?.IncrementDeadLettered(); + _workerInfo?.Stats.IncrementDeadLettered(); if (_stateStore is not null) - await _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken)).ConfigureAwait(false); await DeadLetterAsync(message, $"Exceeded max attempts ({_options.MaxAttempts})").ConfigureAwait(false); return; @@ -199,13 +208,13 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s var linkedToken = timeoutCts.Token; - QueueContext queueContext = null!; + QueueContext? queueContext = null; try { // Update state to Processing if (trackProgress && jobId is not null) - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Processing, startedUtc: _timeProvider.GetUtcNow(), attempt: message.DequeueCount, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Processing, startedUtc: _timeProvider.GetUtcNow(), attempt: message.DequeueCount, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); // Deserialize body to typed message var typedMessage = JsonSerializer.Deserialize(message.Body.Span, _options.MessageType, _jsonOptions); @@ -215,7 +224,7 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s message.Id, _options.QueueName, _options.MessageType.Name); if (jobId is not null) - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: $"Deserialization returned null for type {_options.MessageType.Name}", expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: $"Deserialization returned null for type {_options.MessageType.Name}", expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); await DeadLetterAsync(message, $"Deserialization returned null for type {_options.MessageType.Name}").ConfigureAwait(false); return; } @@ -263,15 +272,15 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s result.Status, message.Id, _options.QueueName, errorMessage); if (jobId is not null) - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: errorMessage, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: errorMessage, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); if (_options.AutoComplete) await AbandonAsync(message, stoppingToken).ConfigureAwait(false); - _workerInfo?.IncrementFailed(); + _workerInfo?.Stats.IncrementFailed(); if (_stateStore is not null) - await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken)).ConfigureAwait(false); return; } @@ -282,35 +291,35 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s result.Status, message.Id, _options.QueueName, errorMessage); if (jobId is not null) - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: errorMessage, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: errorMessage, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); - _workerInfo?.IncrementDeadLettered(); + _workerInfo?.Stats.IncrementDeadLettered(); if (_stateStore is not null) - await _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken)).ConfigureAwait(false); await DeadLetterAsync(message, errorMessage).ConfigureAwait(false); return; } } - if (_options.AutoComplete && !queueContext.IsCompleted && !queueContext.IsAbandoned) + if (_options.AutoComplete && queueContext is { IsCompleted: false, IsAbandoned: false }) await _client.CompleteAsync(message, stoppingToken).ConfigureAwait(false); - _workerInfo?.IncrementProcessed(); + _workerInfo?.Stats.IncrementProcessed(); if (_stateStore is not null) - await _stateStore.IncrementCounterAsync(_options.QueueName, "processed", 1, stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "processed", 1, stoppingToken)).ConfigureAwait(false); // Update state to Completed if (jobId is not null) - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Completed, completedUtc: _timeProvider.GetUtcNow(), progress: 100, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Completed, completedUtc: _timeProvider.GetUtcNow(), progress: 100, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { // Host is shutting down β€” abandon so message becomes visible for retry _logger.LogDebug("Host stopping, abandoning message {MessageId} on {QueueName}", message.Id, _options.QueueName); - if (!queueContext.IsCompleted && !queueContext.IsAbandoned) + if (queueContext is not { IsCompleted: true } and not { IsAbandoned: true }) await AbandonAsync(message).ConfigureAwait(false); } catch (OperationCanceledException) when (trackProgress && jobId is not null && !stoppingToken.IsCancellationRequested) @@ -320,36 +329,36 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s if (wasCancellationRequested) { _logger.LogInformation("Message {MessageId} on {QueueName} was cancelled by user (job {JobId})", message.Id, _options.QueueName, jobId); - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Cancelled, completedUtc: _timeProvider.GetUtcNow(), expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Cancelled, completedUtc: _timeProvider.GetUtcNow(), expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); // User cancellation is a normal completion β€” complete the message so it // doesn't get retried or dead-lettered. - if (_options.AutoComplete && !queueContext.IsCompleted && !queueContext.IsAbandoned) + if (_options.AutoComplete && queueContext is { IsCompleted: false, IsAbandoned: false }) await _client.CompleteAsync(message, stoppingToken).ConfigureAwait(false); } else { _logger.LogWarning("Message {MessageId} on {QueueName} timed out after {Timeout}", message.Id, _options.QueueName, _options.VisibilityTimeout); - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: $"Timed out after {_options.VisibilityTimeout}", expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); - if (_options.AutoComplete && !queueContext.IsCompleted && !queueContext.IsAbandoned) + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: $"Timed out after {_options.VisibilityTimeout}", expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); + if (_options.AutoComplete && queueContext is { IsCompleted: false, IsAbandoned: false }) await AbandonAsync(message, stoppingToken).ConfigureAwait(false); - _workerInfo?.IncrementFailed(); + _workerInfo?.Stats.IncrementFailed(); - await _stateStore!.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken)).ConfigureAwait(false); } } catch (OperationCanceledException) { // Per-message timeout (no tracking) _logger.LogWarning("Message {MessageId} on {QueueName} timed out after {Timeout}", message.Id, _options.QueueName, _options.VisibilityTimeout); - if (_options.AutoComplete && !queueContext.IsCompleted && !queueContext.IsAbandoned) + if (_options.AutoComplete && queueContext is { IsCompleted: false, IsAbandoned: false }) await AbandonAsync(message, stoppingToken).ConfigureAwait(false); - _workerInfo?.IncrementFailed(); + _workerInfo?.Stats.IncrementFailed(); if (_stateStore is not null) - await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken)).ConfigureAwait(false); } catch (Exception ex) { @@ -357,15 +366,15 @@ private async Task ProcessMessageAsync(QueueMessage message, CancellationToken s message.Id, _options.QueueName, message.DequeueCount, _options.MaxAttempts); if (jobId is not null) - await _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: ex.Message, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: ex.Message, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); - if (_options.AutoComplete && !queueContext.IsCompleted && !queueContext.IsAbandoned) + if (_options.AutoComplete && queueContext is { IsCompleted: false, IsAbandoned: false }) await AbandonAsync(message, stoppingToken).ConfigureAwait(false); - _workerInfo?.IncrementFailed(); + _workerInfo?.Stats.IncrementFailed(); if (_stateStore is not null) - await _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken)).ConfigureAwait(false); } finally { @@ -444,16 +453,38 @@ private async Task UpdateJobProgressAsync(string jobId, int percent, string? mes { if (_stateStore is null) return; - // Check for cancellation on every progress report + // Check for cancellation on every progress report β€” this is intentionally NOT wrapped + // in TryUpdateStateAsync because cancellation failures must propagate. if (await _stateStore.IsCancellationRequestedAsync(jobId, ct).ConfigureAwait(false)) throw new OperationCanceledException("Job cancellation was requested."); - await _stateStore.UpdateJobProgressAsync(jobId, Math.Clamp(percent, 0, 100), message, s_defaultStateExpiry, ct).ConfigureAwait(false); + await TryUpdateStateAsync(() => _stateStore.UpdateJobProgressAsync(jobId, Math.Clamp(percent, 0, 100), message, s_defaultStateExpiry, ct)).ConfigureAwait(false); + } + + /// + /// Executes a state store operation, catching and logging failures so they don't + /// prevent message processing. State store unavailability is transient and should + /// not cause messages to be abandoned or dead-lettered. + /// + private async Task TryUpdateStateAsync(Func operation) + { + try + { + await operation().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Always propagate cancellation + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update job state store for queue {QueueName}; message processing will continue", _options.QueueName); + } } private async Task AbandonAsync(QueueMessage message, CancellationToken cancellationToken) { - var delay = ComputeRetryDelay(message.DequeueCount); + var delay = QueueRetryDelay.Compute(_options.RetryPolicy, _options.RetryDelay, message.DequeueCount); if (delay > TimeSpan.Zero) { try @@ -471,34 +502,6 @@ private async Task AbandonAsync(QueueMessage message, CancellationToken cancella await AbandonAsync(message).ConfigureAwait(false); } - public TimeSpan ComputeRetryDelay(int dequeueCount) - { - if (_options.RetryPolicy == QueueRetryPolicy.None) - return TimeSpan.Zero; - - var baseDelay = _options.RetryDelay; - if (baseDelay <= TimeSpan.Zero) - return TimeSpan.Zero; - - // dequeueCount is 1-based; first retry is after attempt 1 - int retryNumber = Math.Max(0, dequeueCount - 1); - - double delayMs = _options.RetryPolicy switch - { - QueueRetryPolicy.Fixed => baseDelay.TotalMilliseconds, - QueueRetryPolicy.Exponential => baseDelay.TotalMilliseconds * Math.Pow(2, retryNumber), - _ => 0 - }; - - // Apply proportional jitter (Β±10% of the computed delay) - double jitterRange = delayMs * 0.1; - double jitter = (Random.Shared.NextDouble() * 2 - 1) * jitterRange; - delayMs = Math.Max(0, delayMs + jitter); - - // Cap at 15 minutes to prevent unreasonably long delays - return TimeSpan.FromMilliseconds(Math.Min(delayMs, TimeSpan.FromMinutes(15).TotalMilliseconds)); - } - private async Task AbandonAsync(QueueMessage message) { try diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs index 6a3026c9..55d2182e 100644 --- a/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs @@ -1,7 +1,8 @@ namespace Foundatio.Mediator.Distributed; /// -/// Describes a registered queue worker and its runtime statistics. +/// Describes a registered queue worker's configuration and provides access to runtime statistics. +/// Configuration properties are immutable after initialization. /// Exposed via for dashboard and monitoring. /// public sealed class QueueWorkerInfo @@ -56,42 +57,8 @@ public sealed class QueueWorkerInfo /// public string? Description { get; init; } - // --- Runtime stats (updated atomically by QueueWorker) --- - - private long _messagesProcessed; - private long _messagesFailed; - private long _messagesDeadLettered; - private volatile bool _isRunning; - - /// - /// Whether a hosted service was registered for this queue - /// in the current process. When false, the worker metadata is available for - /// dashboard visibility but no local processing occurs (e.g., API-only nodes). - /// - public bool WorkerRegistered { get; internal set; } - - /// - /// Total messages processed successfully since startup. - /// - public long MessagesProcessed => Interlocked.Read(ref _messagesProcessed); - /// - /// Total messages that failed processing since startup. + /// Runtime statistics for this worker. Updated atomically by the worker during message processing. /// - public long MessagesFailed => Interlocked.Read(ref _messagesFailed); - - /// - /// Total messages dead-lettered since startup. - /// - public long MessagesDeadLettered => Interlocked.Read(ref _messagesDeadLettered); - - /// - /// Whether the worker is currently running. - /// - public bool IsRunning => _isRunning; - - internal void IncrementProcessed() => Interlocked.Increment(ref _messagesProcessed); - internal void IncrementFailed() => Interlocked.Increment(ref _messagesFailed); - internal void IncrementDeadLettered() => Interlocked.Increment(ref _messagesDeadLettered); - internal void SetRunning(bool running) => _isRunning = running; + public QueueWorkerStats Stats { get; } = new(); } diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerStats.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerStats.cs new file mode 100644 index 00000000..d335d73d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerStats.cs @@ -0,0 +1,47 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Thread-safe runtime statistics for a queue worker, updated atomically during message processing. +/// Exposed via for dashboard and monitoring. +/// +public sealed class QueueWorkerStats +{ + private long _messagesProcessed; + private long _messagesFailed; + private long _messagesDeadLettered; + private volatile bool _isRunning; + private volatile bool _workerRegistered; + + /// + /// Whether a hosted service was registered for this queue + /// in the current process. When false, the worker metadata is available for + /// dashboard visibility but no local processing occurs (e.g., API-only nodes). + /// + public bool WorkerRegistered => _workerRegistered; + + /// + /// Total messages processed successfully since startup. + /// + public long MessagesProcessed => Interlocked.Read(ref _messagesProcessed); + + /// + /// Total messages that failed processing since startup. + /// + public long MessagesFailed => Interlocked.Read(ref _messagesFailed); + + /// + /// Total messages dead-lettered since startup. + /// + public long MessagesDeadLettered => Interlocked.Read(ref _messagesDeadLettered); + + /// + /// Whether the worker is currently running. + /// + public bool IsRunning => _isRunning; + + internal void IncrementProcessed() => Interlocked.Increment(ref _messagesProcessed); + internal void IncrementFailed() => Interlocked.Increment(ref _messagesFailed); + internal void IncrementDeadLettered() => Interlocked.Increment(ref _messagesDeadLettered); + internal void SetRunning(bool running) => _isRunning = running; + internal void SetWorkerRegistered(bool registered) => _workerRegistered = registered; +} diff --git a/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs index a2377925..1d79db7b 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs @@ -1,55 +1,30 @@ using Foundatio.Mediator.Distributed; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Foundatio.Mediator.Distributed.Tests; public class ComputeRetryDelayTests { - private static QueueWorker CreateWorker(QueueRetryPolicy policy, TimeSpan baseDelay) - { - var options = new QueueWorkerOptions - { - QueueName = "test", - MessageType = typeof(object), - Registration = null!, - RetryPolicy = policy, - RetryDelay = baseDelay - }; - - return new QueueWorker( - new InMemoryQueueClient(), - new ServiceCollection().BuildServiceProvider().GetRequiredService(), - options, - null, - NullLogger.Instance); - } - [Fact] public void None_ReturnsZero() { - var worker = CreateWorker(QueueRetryPolicy.None, TimeSpan.FromSeconds(5)); - Assert.Equal(TimeSpan.Zero, worker.ComputeRetryDelay(1)); - Assert.Equal(TimeSpan.Zero, worker.ComputeRetryDelay(5)); + Assert.Equal(TimeSpan.Zero, QueueRetryDelay.Compute(QueueRetryPolicy.None, TimeSpan.FromSeconds(5), 1)); + Assert.Equal(TimeSpan.Zero, QueueRetryDelay.Compute(QueueRetryPolicy.None, TimeSpan.FromSeconds(5), 5)); } [Fact] public void ZeroBaseDelay_ReturnsZero() { - var worker = CreateWorker(QueueRetryPolicy.Exponential, TimeSpan.Zero); - Assert.Equal(TimeSpan.Zero, worker.ComputeRetryDelay(3)); + Assert.Equal(TimeSpan.Zero, QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, TimeSpan.Zero, 3)); } [Fact] public void Fixed_ReturnsSameDelayWithinJitterBounds() { var baseDelay = TimeSpan.FromSeconds(10); - var worker = CreateWorker(QueueRetryPolicy.Fixed, baseDelay); for (int attempt = 1; attempt <= 5; attempt++) { - var delay = worker.ComputeRetryDelay(attempt); + var delay = QueueRetryDelay.Compute(QueueRetryPolicy.Fixed, baseDelay, attempt); // Fixed: always ~baseDelay Β±10% jitter Assert.InRange(delay.TotalMilliseconds, baseDelay.TotalMilliseconds * 0.9, @@ -61,22 +36,21 @@ public void Fixed_ReturnsSameDelayWithinJitterBounds() public void Exponential_DoublesEachRetry() { var baseDelay = TimeSpan.FromSeconds(5); - var worker = CreateWorker(QueueRetryPolicy.Exponential, baseDelay); // dequeueCount=1 β†’ retryNumber=0 β†’ 5s * 2^0 = 5s - var delay1 = worker.ComputeRetryDelay(1); + var delay1 = QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, baseDelay, 1); Assert.InRange(delay1.TotalSeconds, 4.5, 5.5); // dequeueCount=2 β†’ retryNumber=1 β†’ 5s * 2^1 = 10s - var delay2 = worker.ComputeRetryDelay(2); + var delay2 = QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, baseDelay, 2); Assert.InRange(delay2.TotalSeconds, 9.0, 11.0); // dequeueCount=3 β†’ retryNumber=2 β†’ 5s * 2^2 = 20s - var delay3 = worker.ComputeRetryDelay(3); + var delay3 = QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, baseDelay, 3); Assert.InRange(delay3.TotalSeconds, 18.0, 22.0); // dequeueCount=4 β†’ retryNumber=3 β†’ 5s * 2^3 = 40s - var delay4 = worker.ComputeRetryDelay(4); + var delay4 = QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, baseDelay, 4); Assert.InRange(delay4.TotalSeconds, 36.0, 44.0); } @@ -84,10 +58,9 @@ public void Exponential_DoublesEachRetry() public void Exponential_CapsAt15Minutes() { var baseDelay = TimeSpan.FromSeconds(5); - var worker = CreateWorker(QueueRetryPolicy.Exponential, baseDelay); // dequeueCount=20 β†’ retryNumber=19 β†’ 5s * 2^19 = 2,621,440s (way over 15min) - var delay = worker.ComputeRetryDelay(20); + var delay = QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, baseDelay, 20); Assert.True(delay <= TimeSpan.FromMinutes(15), $"Expected <= 15 minutes but got {delay}"); // Should be at the cap (within jitter) @@ -98,11 +71,10 @@ public void Exponential_CapsAt15Minutes() public void JitterIsProportional() { var baseDelay = TimeSpan.FromSeconds(10); - var worker = CreateWorker(QueueRetryPolicy.Fixed, baseDelay); // Run many iterations to verify jitter stays within Β±10% var delays = Enumerable.Range(0, 100) - .Select(_ => worker.ComputeRetryDelay(1).TotalMilliseconds) + .Select(_ => QueueRetryDelay.Compute(QueueRetryPolicy.Fixed, baseDelay, 1).TotalMilliseconds) .ToList(); var min = delays.Min(); diff --git a/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs index 9d7d683b..8bad3ebc 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/QueueWorkerJobTrackingTests.cs @@ -328,9 +328,9 @@ public async Task WorkerInfo_TracksRuntimeStats() var worker = registry.GetWorker("TrackedCommand"); Assert.NotNull(worker); - Assert.Equal(2, worker.MessagesProcessed); - Assert.Equal(0, worker.MessagesFailed); - Assert.True(worker.IsRunning); + Assert.Equal(2, worker.Stats.MessagesProcessed); + Assert.Equal(0, worker.Stats.MessagesFailed); + Assert.True(worker.Stats.IsRunning); } finally { From 846d4832771c6e9bce8eb996d52ca8cbafc4f1fd Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 7 Apr 2026 22:57:43 -0500 Subject: [PATCH 21/27] Updates --- .../{SnsSqsPubSubClient.cs => SqsPubSubClient.cs} | 0 .../{SnsSqsPubSubClientOptions.cs => SqsPubSubClientOptions.cs} | 0 .../{SnsSqsPubSubClientTests.cs => SqsPubSubClientTests.cs} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/Foundatio.Mediator.Distributed.Aws/{SnsSqsPubSubClient.cs => SqsPubSubClient.cs} (100%) rename src/Foundatio.Mediator.Distributed.Aws/{SnsSqsPubSubClientOptions.cs => SqsPubSubClientOptions.cs} (100%) rename tests/Foundatio.Mediator.Distributed.Aws.Tests/{SnsSqsPubSubClientTests.cs => SqsPubSubClientTests.cs} (100%) diff --git a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs similarity index 100% rename from src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClient.cs rename to src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs diff --git a/src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClientOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClientOptions.cs similarity index 100% rename from src/Foundatio.Mediator.Distributed.Aws/SnsSqsPubSubClientOptions.cs rename to src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClientOptions.cs diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs similarity index 100% rename from tests/Foundatio.Mediator.Distributed.Aws.Tests/SnsSqsPubSubClientTests.cs rename to tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs From 75387aac07d7ef1b0d669db8b92cd95b7bd1cd34 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 7 Apr 2026 22:57:53 -0500 Subject: [PATCH 22/27] More updates --- .../AwsServiceExtensions.cs | 12 ++++++------ .../AwsTransportOptions.cs | 4 ++-- .../SqsPubSubClient.cs | 16 ++++++++-------- .../SqsPubSubClientOptions.cs | 2 +- .../SqsPubSubClientTests.cs | 10 +++++----- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Foundatio.Mediator.Distributed.Aws/AwsServiceExtensions.cs b/src/Foundatio.Mediator.Distributed.Aws/AwsServiceExtensions.cs index 99dced2e..09d00cc6 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/AwsServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/AwsServiceExtensions.cs @@ -96,11 +96,11 @@ public static IMediatorBuilder UseAwsQueues( } /// - /// Registers as the implementation. + /// Registers as the implementation. /// Requires IAmazonSimpleNotificationService and IAmazonSQS to be registered in DI. /// /// The mediator builder. - /// Optional configuration for . + /// Optional configuration for . /// The mediator builder for chaining. /// /// @@ -113,19 +113,19 @@ public static IMediatorBuilder UseAwsQueues( /// public static IMediatorBuilder UseAwsNotifications( this IMediatorBuilder builder, - Action? configure = null) + Action? configure = null) { var services = builder.Services; - var options = new SnsSqsPubSubClientOptions(); + var options = new SqsPubSubClientOptions(); configure?.Invoke(options); services.AddSingleton(options); - services.AddSingleton(sp => new SnsSqsPubSubClient( + services.AddSingleton(sp => new SqsPubSubClient( sp.GetRequiredService(), sp.GetRequiredService(), options, sp.GetRequiredService(), - sp.GetRequiredService>())); + sp.GetRequiredService>())); return builder; } diff --git a/src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs index 5a969ae1..b66ff2ce 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs @@ -35,7 +35,7 @@ public class AwsTransportOptions public SqsQueueClientOptions Queues { get; set; } = new(); /// - /// Options for the SNS/SQS pub/sub client. See . + /// Options for the SNS/SQS pub/sub client. See . /// - public SnsSqsPubSubClientOptions Notifications { get; set; } = new(); + public SqsPubSubClientOptions Notifications { get; set; } = new(); } diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs index 32f7dfc8..e1958ffe 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs @@ -13,26 +13,26 @@ namespace Foundatio.Mediator.Distributed.Aws; /// per-node SQS queues for subscription. Each subscriber creates a dedicated SQS queue /// subscribed to the SNS topic, enabling true pub/sub fan-out across nodes. /// -public sealed class SnsSqsPubSubClient : IPubSubClient, IAsyncDisposable +public sealed class SqsPubSubClient : IPubSubClient, IAsyncDisposable { private readonly IAmazonSimpleNotificationService _sns; private readonly IAmazonSQS _sqs; - private readonly SnsSqsPubSubClientOptions _options; + private readonly SqsPubSubClientOptions _options; private readonly string _hostId; private readonly string? _resourcePrefix; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ConcurrentDictionary _topicArnCache = new(); private readonly ConcurrentDictionary _subscriptionSetupCache = new(); private readonly ConcurrentBag _activeSubscriptions = []; private readonly SemaphoreSlim _queueSetupLock = new(1, 1); private (string QueueName, string QueueUrl, string QueueArn)? _sharedQueue; - public SnsSqsPubSubClient( + public SqsPubSubClient( IAmazonSimpleNotificationService sns, IAmazonSQS sqs, - SnsSqsPubSubClientOptions options, + SqsPubSubClientOptions options, DistributedNotificationOptions notificationOptions, - ILogger logger) + ILogger logger) { _sns = sns; _sqs = sqs; @@ -372,7 +372,7 @@ private sealed class SubscriptionHandle( CancellationTokenSource cts, Task pollTask, IAmazonSimpleNotificationService sns, - SnsSqsPubSubClientOptions options, + SqsPubSubClientOptions options, ILogger logger) : IAsyncDisposable { private int _disposed; @@ -404,7 +404,7 @@ await sns.UnsubscribeAsync(new UnsubscribeRequest subscriptionArn, topicArn); } - // Shared per-node SQS queue is cleaned up by SnsSqsPubSubClient.DisposeAsync() + // Shared per-node SQS queue is cleaned up by SqsPubSubClient.DisposeAsync() } } } diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClientOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClientOptions.cs index dff79a14..9735bc58 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClientOptions.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClientOptions.cs @@ -3,7 +3,7 @@ namespace Foundatio.Mediator.Distributed.Aws; /// /// Options for configuring the SNS+SQS pub/sub client. /// -public class SnsSqsPubSubClientOptions +public class SqsPubSubClientOptions { /// /// The SNS topic name. This is used to create or look up the topic. diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs index 64d28cee..850957f7 100644 --- a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs @@ -12,10 +12,10 @@ namespace Foundatio.Mediator.Distributed.Aws.Tests; /// /// SNS+SQS pub/sub client tests running against LocalStack managed by Aspire. /// -public class SnsSqsPubSubClientTests(LocalStackFixture fixture, ITestOutputHelper output) +public class SqsPubSubClientTests(LocalStackFixture fixture, ITestOutputHelper output) : TestWithLoggingBase(output), IClassFixture { - private SnsSqsPubSubClient CreateClient(string? hostId = null) + private SqsPubSubClient CreateClient(string? hostId = null) { var credentials = new BasicAWSCredentials("test", "test"); @@ -27,7 +27,7 @@ private SnsSqsPubSubClient CreateClient(string? hostId = null) credentials, new AmazonSQSConfig { ServiceURL = fixture.ServiceUrl }); - var options = new SnsSqsPubSubClientOptions + var options = new SqsPubSubClientOptions { AutoCreate = true, WaitTimeSeconds = 1, @@ -40,12 +40,12 @@ private SnsSqsPubSubClient CreateClient(string? hostId = null) Topic = $"test-topic-{Guid.NewGuid():N}" }; - return new SnsSqsPubSubClient( + return new SqsPubSubClient( snsClient, sqsClient, options, notificationOptions, - Log.CreateLogger()); + Log.CreateLogger()); } [Fact] From c9260e6f254ac0add419fcc87cc53396b8005896 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 7 Apr 2026 23:04:56 -0500 Subject: [PATCH 23/27] Update sample readme --- samples/CleanArchitectureSample/README.md | 136 +++++++++++++++------- 1 file changed, 91 insertions(+), 45 deletions(-) diff --git a/samples/CleanArchitectureSample/README.md b/samples/CleanArchitectureSample/README.md index 55a276ef..0038c3a1 100644 --- a/samples/CleanArchitectureSample/README.md +++ b/samples/CleanArchitectureSample/README.md @@ -26,7 +26,10 @@ A working modular monolith that showcases Foundatio.Mediator's features in a rea | **Assembly configuration** | `[assembly: MediatorConfiguration(AuthorizationRequired = true, ...)]` per module | | **Distributed notifications** | Domain events implement `IDistributedNotification` β€” fan out across all replicas via SNS+SQS | | **Async queue handlers** | `[Queue]` on `AuditEventHandler` / `NotificationEventHandler` β€” processed via SQS | -| **Aspire orchestration** | `AppHost` runs 3 API replicas + LocalStack (SQS/SNS) for local development | +| **Job progress tracking** | `DemoExportJobHandler` with `TrackProgress = true`, progress reporting via `QueueContext` | +| **Queue dashboard** | `QueueDashboardHandler` β€” built-in endpoints for listing queues, job state, counters, and cancellation | +| **Queue dashboard UI** | SvelteKit page with real-time throughput sparklines, job progress bars, and cancellation | +| **Aspire orchestration** | `AppHost` runs 3 API replicas + LocalStack (SQS/SNS) + Redis for local development | ## Project Structure @@ -38,6 +41,8 @@ src/ β”‚ β”œβ”€β”€ Handlers/ β”‚ β”‚ β”œβ”€β”€ AuditEventHandler.cs # [Queue] β€” async audit logging via SQS β”‚ β”‚ β”œβ”€β”€ NotificationEventHandler.cs # [Queue] β€” async notification delivery via SQS +β”‚ β”‚ β”œβ”€β”€ DemoExportJobHandler.cs # [Queue(TrackProgress=true)] β€” long-running job with progress +β”‚ β”‚ β”œβ”€β”€ QueueDashboardHandler.cs # Queue monitoring endpoints (list, stats, cancel) β”‚ β”‚ └── HealthHandler.cs # [HandlerAllowAnonymous] health check β”‚ β”œβ”€β”€ Middleware/ β”‚ β”‚ β”œβ”€β”€ ObservabilityMiddleware.cs # Before/After/Finally with Stopwatch state @@ -77,7 +82,7 @@ src/ β”‚ └── Handlers/ β”‚ └── ClientEventStreamHandler.cs # Streaming SSE endpoint for real-time events β”‚ -β”œβ”€β”€ AppHost/ # Aspire orchestrator (3 API replicas + LocalStack) +β”œβ”€β”€ AppHost/ # Aspire orchestrator (3 API replicas + LocalStack + Redis) β”‚ └── Program.cs β”‚ β”œβ”€β”€ ServiceDefaults/ # Aspire service defaults (OpenTelemetry, health checks) @@ -443,17 +448,11 @@ public class AuditEventHandler(IAuditService auditService, ILogger(_ => new AmazonSQSClient(...)); -builder.Services.AddSingleton(_ => new AmazonSimpleNotificationServiceClient(...)); - -// Async queue processing via SQS -builder.Services.AddMediatorSqs(); -builder.Services.AddMediatorDistributed(); - -// Distributed notification fan-out via SNS+SQS -builder.Services.AddMediatorSnsSqsPubSub(); -builder.Services.AddMediatorDistributedNotifications(); +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications() + .UseAws(aws => aws.ServiceUrl = builder.Configuration["AWS:ServiceURL"]!) + .UseRedisJobState(); ``` ### 12. Result Pattern @@ -472,6 +471,65 @@ public async Task> HandleAsync(GetOrder query, CancellationToken c } ``` +### 13. Job Progress Tracking + +The `DemoExportJobHandler` shows a long-running queue job with progress reporting. The `[Queue(TrackProgress = true)]` attribute enables the job state store (Redis in this sample) to track status, progress percentage, and support cancellation: + +```csharp +[Queue(TrackProgress = true, Concurrency = 5, TimeoutSeconds = 10)] +public class DemoExportJobHandler(ILogger logger) +{ + public async Task HandleAsync(DemoExportJob message, QueueContext queueContext, CancellationToken ct) + { + for (int i = 1; i <= message.Steps; i++) + { + ct.ThrowIfCancellationRequested(); + + await Task.Delay(message.StepDelayMs, ct); + + int percent = (int)((double)i / message.Steps * 100); + await queueContext.ReportProgressAsync(percent, $"Step {i}/{message.Steps}"); + } + + return Result.Ok(); + } +} +``` + +Key points: + +- **`QueueContext`** is injected as a handler parameter β€” provides `ReportProgressAsync()`, `AcknowledgeAsync()`, `RejectAsync()`, `DeferAsync()` +- **`Result.Error()`** tells the worker to abandon and retry; **`Result.CriticalError()`** dead-letters immediately +- **Cancellation** is checked via `CancellationToken` β€” the queue dashboard can request cancellation through the job state store + +### 14. Queue Dashboard + +The `QueueDashboardHandler` exposes queue monitoring as mediator endpoints under `/api/queues`. The SvelteKit frontend provides a real-time dashboard with throughput sparklines, job progress bars, and cancellation: + +```csharp +[HandlerEndpointGroup("Queues")] +[HandlerAllowAnonymous] +public class QueueDashboardHandler +{ + // GET /api/queues/queues β€” list all registered queue workers with stats + [Cached(DurationSeconds = 2)] + public async Task>> HandleAsync(GetQueues query, ...) { ... } + + // GET /api/queues/job-dashboard β€” job state with active/recent jobs + [Cached(DurationSeconds = 2)] + public async Task> HandleAsync(GetJobDashboard query, ...) { ... } + + // POST /api/queues/cancel-job β€” request job cancellation + public async Task HandleAsync(CancelJob command, ...) { ... } +} +``` + +The frontend (`/queues` route) polls these endpoints and renders: + +- **Per-queue throughput** β€” processed/failed/dead-lettered sparkline charts +- **Active jobs** β€” progress bars with percentage and status message +- **Recent jobs** β€” completed/failed/cancelled with duration + ## Module Dependencies Modules reference other modules only for message/DTO types β€” never for handlers, repositories, or services: @@ -500,11 +558,11 @@ Common.Module (no module dependencies) - .NET 10 SDK - Node.js 20+ (for the frontend) -- Docker (for Aspire + LocalStack) +- Docker (for Aspire, LocalStack, and Redis) -### Quick Start (with Aspire) +### Quick Start -The recommended way to run uses Aspire to orchestrate 3 API replicas with a LocalStack container providing SQS and SNS: +The sample runs via Aspire, which orchestrates separate API and worker processes with LocalStack (SQS/SNS) and Redis: ```bash cd samples/CleanArchitectureSample/src/AppHost @@ -513,56 +571,44 @@ dotnet run This starts: -- **3 API replicas** β€” demonstrating distributed notification fan-out +- **3 API replicas** β€” serve HTTP endpoints and the SPA frontend (no queue workers) +- **3 Worker replicas** β€” process all queues (no API endpoints) - **LocalStack** β€” provides SQS (async queue processing) and SNS (pub/sub notifications) -- **Aspire Dashboard** β€” view traces, logs, and metrics at the URL shown in terminal output - -### Quick Start (standalone) - -To run a single instance without Aspire/Docker: - -1. **Install frontend dependencies** (first time only): - - ```bash - cd samples/CleanArchitectureSample/src/Web - npm install - ``` +- **Redis** β€” shared persistence, distributed caching, and job state tracking +- **Vite frontend** β€” SvelteKit SPA at `https://localhost:5199` +- **Aspire Dashboard** β€” traces, logs, and metrics at the URL shown in terminal output -2. **Run the application:** - - **VS Code**: Run the "Clean Architecture Sample" launch configuration - - **Visual Studio**: Set `Api` as startup project and press F5 - - **CLI**: `dotnet run --project samples/CleanArchitectureSample/src/Api` - -Without the `AWS:ServiceURL` environment variable, the app falls back to in-memory queue and pub/sub client implementations. - -The SPA Proxy starts the Vite dev server automatically. +The API and worker processes share the same `Api` project β€” the `--mode api`/`--mode worker` argument controls which features are active. ### URLs +All URLs are assigned dynamically by Aspire β€” check the Aspire Dashboard for the actual ports. The frontend is the exception: + | URL | Description | | --- | ----------- | -| `https://localhost:5173` | SvelteKit frontend | -| `https://localhost:58702/api/*` | Backend API | -| `https://localhost:58702/scalar/v1` | API docs (Scalar) | +| `https://localhost:5199` | SvelteKit frontend (fixed port) | +| Aspire Dashboard | API endpoints, traces, logs, metrics | ### Try the API +Use the frontend at `https://localhost:5199` or call the API directly (find the API URL from the Aspire Dashboard): + ```bash # Create a product (requires Admin login) -curl -X POST https://localhost:58702/api/products \ +curl -X POST https://localhost:{port}/api/products \ -H "Content-Type: application/json" \ -d '{"name":"Widget","description":"A great widget","price":29.99,"stockQuantity":50}' # Create an order -curl -X POST https://localhost:58702/api/orders \ +curl -X POST https://localhost:{port}/api/orders \ -H "Content-Type: application/json" \ -d '{"customerId":"customer-123","amount":29.99,"description":"Widget purchase"}' # Dashboard report (aggregates from both modules) -curl https://localhost:58702/api/reports +curl https://localhost:{port}/api/reports # Search across modules -curl "https://localhost:58702/api/reports/search-catalog?searchTerm=widget" +curl "https://localhost:{port}/api/reports/search-catalog?searchTerm=widget" ``` Demo users: `admin`/`admin` (Admin role), `user`/`user` (User role). @@ -581,4 +627,4 @@ dbug: Sending order confirmation notification for order abc123 ### Frontend -The SvelteKit frontend (Svelte 5, Tailwind CSS, TypeScript) provides a dashboard, CRUD pages for Orders and Products, and reporting views. During development, Vite proxies `/api/*` requests to the backend. +The SvelteKit frontend (Svelte 5, Tailwind CSS, TypeScript) provides a dashboard, CRUD pages for Orders and Products, a queue dashboard with live job progress, and a live events page. Aspire runs the Vite dev server and proxies `/api/*` requests to the API replicas. From 47eb6bcf773ff9e26c96242af2d3977c429452aa Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 7 Apr 2026 23:49:32 -0500 Subject: [PATCH 24/27] Cleanup telemetry --- .../src/Api/Program.cs | 1 + .../src/ServiceDefaults/Extensions.cs | 45 ++++++++++++++++--- .../ServiceDefaults/ServiceDefaults.csproj | 1 + 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/samples/CleanArchitectureSample/src/Api/Program.cs b/samples/CleanArchitectureSample/src/Api/Program.cs index 8009db13..9bed1708 100644 --- a/samples/CleanArchitectureSample/src/Api/Program.cs +++ b/samples/CleanArchitectureSample/src/Api/Program.cs @@ -44,6 +44,7 @@ app.LogStartupDiagnostics(options); app.MapHealthCheckEndpoints(); +app.UseSuppressInstrumentation("/api/queues/queues", "/api/queues/job-dashboard", "/api/events"); if (options.IsApiEnabled) { diff --git a/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs b/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs index 261c1eae..3c71f0f9 100644 --- a/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs @@ -45,9 +45,12 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w { tracing.AddAspNetCoreInstrumentation(o => { + // Drop the root HTTP span for noisy polling endpoints. + // The SuppressInstrumentation middleware in Program.cs prevents child spans. o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/api/events") - && !ctx.Request.Path.StartsWithSegments("/api/queues"); + && ctx.Request.Path != "/api/queues/queues" + && ctx.Request.Path != "/api/queues/job-dashboard"; }) .AddHttpClientInstrumentation(o => { @@ -60,11 +63,16 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w { o.SuppressDownstreamInstrumentation = true; }) - .AddSource("Foundatio.Mediator"); + .AddSource("Foundatio.Mediator") + .AddRedisInstrumentation(); - // Drop noisy SQS polling/housekeeping spans from the AWS SDK instrumentation + // Drop noisy background spans: + // - SQS polling from queue workers + // - Orphaned Redis spans from job state store operations (keep Redis spans that are + // children of an application trace, drop root-level infrastructure noise) tracing.AddProcessor(new FilteringProcessor(activity => - activity.OperationName is not "SQS.ReceiveMessage" and not "SQS.DeleteMessage")); + activity.OperationName is not "SQS.ReceiveMessage" and not "SQS.DeleteMessage" + && !(activity.Source.Name == "OpenTelemetry.Instrumentation.StackExchangeRedis" && activity.Parent is null))); }); builder.AddOpenTelemetryExporters(); @@ -102,10 +110,37 @@ public static WebApplication MapHealthCheckEndpoints(this WebApplication app) return app; } + + /// + /// Suppresses all OpenTelemetry instrumentation for requests matching the given paths. + /// No activities (spans) are created for the request or any downstream calls (Redis, SQS, etc.). + /// + public static WebApplication UseSuppressInstrumentation(this WebApplication app, params string[] pathPrefixes) + { + app.Use(async (context, next) => + { + foreach (var prefix in pathPrefixes) + { + if (context.Request.Path.StartsWithSegments(prefix)) + { + using (SuppressInstrumentationScope.Begin()) + { + await next(); + } + return; + } + } + + await next(); + }); + + return app; + } } /// -/// Drops activities that don't match the predicate so they are never exported. +/// Drops activities that match the predicate so they are never exported. +/// Used for background worker spans (SQS polling) that aren't HTTP requests. /// internal sealed class FilteringProcessor(Func predicate) : BaseProcessor { diff --git a/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj b/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj index aec702e0..e1d56422 100644 --- a/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj @@ -21,6 +21,7 @@ + From 0760ba26cc684051bc999d773a798d1a6ae9d330 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 9 Apr 2026 10:14:47 -0500 Subject: [PATCH 25/27] Manual distributed version for now --- build/common.props | 1 + build/distributed.props | 10 + docs/demo-script.md | 679 ++++++++++++++++++ .../Foundatio.Mediator.Distributed.Aws.csproj | 1 + ...oundatio.Mediator.Distributed.Redis.csproj | 1 + .../Foundatio.Mediator.Distributed.csproj | 1 + 6 files changed, 693 insertions(+) create mode 100644 build/distributed.props create mode 100644 docs/demo-script.md diff --git a/build/common.props b/build/common.props index 75e45e48..1b385268 100644 --- a/build/common.props +++ b/build/common.props @@ -10,6 +10,7 @@ https://github.com/FoundatioFx/Foundatio.Mediator/releases Foundatio;Mediator;CQRS;Messaging;SourceGenerator;Interceptors true + beta.0 v Copyright Β© $([System.DateTime]::Now.ToString(yyyy)) Foundatio. All rights reserved. diff --git a/build/distributed.props b/build/distributed.props new file mode 100644 index 00000000..2bc1e36e --- /dev/null +++ b/build/distributed.props @@ -0,0 +1,10 @@ + + + + + true + 0.1.0-beta1 + $(PackageTags);Distributed + + + diff --git a/docs/demo-script.md b/docs/demo-script.md new file mode 100644 index 00000000..7f348680 --- /dev/null +++ b/docs/demo-script.md @@ -0,0 +1,679 @@ +# Foundatio.Mediator β€” Video Demo Script + +> **Total runtime target:** 20–25 minutes +> **Primary demo app:** Clean Architecture Sample (modular monolith) +> **Prerequisites:** .NET 10 SDK, Node.js 20+, Docker running +> **Demo users:** `admin`/`admin` (Admin role), `user`/`user` (User role) + +--- + +## Pre-Demo Setup + +**Before recording, run the app so startup time doesn't eat into the video:** + +```bash +cd samples/CleanArchitectureSample/src/AppHost +dotnet run +``` + +Wait for Aspire Dashboard to show all resources healthy: +- 3 API replicas (green) +- 3 Worker replicas (green) +- LocalStack container (SQS/SNS) +- Redis container +- Vite frontend + +**Have these windows ready:** +1. Browser: SvelteKit frontend at `https://localhost:5199` +2. Browser: Aspire Dashboard (URL from terminal output) +3. IDE: VS Code with the sample open at `samples/CleanArchitectureSample/` +4. Terminal: For running curl commands (optional, the UI covers most) + +--- + +## Part 1: Introduction & Hook (2 min) + +### Script + +> "Foundatio.Mediator is a high-performance mediator library for .NET that uses source generators and C# interceptors to achieve near-direct-call performance β€” with zero runtime reflection. +> +> Today I'm going to walk through a complete modular monolith application that demonstrates everything: convention-based handler discovery, auto-generated API endpoints, a rich middleware pipeline, real-time streaming, and β€” the new part β€” distributed queues and notifications powered by SQS and SNS. +> +> Let me start with the numbers." + +### Show: Benchmark Results + +Open `BenchmarkDotNet.Artifacts/results/Foundatio.Mediator.Benchmarks.CoreBenchmarks-report-github.md` or show this table: + +| Scenario | Foundatio | MediatR | MassTransit | Wolverine | +|----------|-----------|---------|-------------|-----------| +| **Command** | **3.5 ns** | 37 ns | 1,376 ns | 179 ns | +| **Query** | **27 ns** | 61 ns | 5,015 ns | 253 ns | +| **Publish** | **16 ns** | 96 ns | 2,098 ns | 1,889 ns | +| **Cascading** | **151 ns** | 215 ns | 12,769 ns | 3,106 ns | + +> "Commands run at 3.5 nanoseconds β€” that's essentially the same as a direct method call. Queries at 27 nanoseconds. Publishing an event at 16 nanoseconds. This is possible because everything is resolved at compile time β€” the source generator emits direct dispatch code, and C# interceptors redirect your `mediator.InvokeAsync()` calls to those generated methods. No dictionary lookups, no reflection, no allocations on the hot path." + +--- + +## Part 2: Project Structure Overview (2 min) + +### Show: Solution Explorer + +Navigate through the project structure in VS Code: + +``` +samples/CleanArchitectureSample/src/ +β”œβ”€β”€ Common.Module/ β†’ Cross-cutting: middleware, events, shared handlers +β”œβ”€β”€ Orders.Module/ β†’ Order processing bounded context +β”œβ”€β”€ Products.Module/ β†’ Product catalog bounded context +β”œβ”€β”€ Reports.Module/ β†’ Cross-module aggregation +β”œβ”€β”€ Api/ β†’ ASP.NET Core composition root +β”œβ”€β”€ AppHost/ β†’ Aspire orchestrator +└── Web/ β†’ SvelteKit frontend +``` + +### Script + +> "This is a modular monolith β€” four independent domain modules that communicate exclusively through the mediator. No module directly references another's handlers or data layer. The Reports module queries Orders and Products, but only through message types β€” it has no idea how those modules store their data. +> +> The Api project is the composition root. It wires up all the modules, configures distributed messaging, and calls `MapMediatorEndpoints()` to auto-generate all the API routes. The AppHost uses .NET Aspire to orchestrate 3 API replicas, 3 worker replicas, LocalStack for SQS/SNS, and Redis β€” all running locally." + +### Show: `Api/Program.cs` + +Open [samples/CleanArchitectureSample/src/Api/Program.cs](samples/CleanArchitectureSample/src/Api/Program.cs) and highlight: + +```csharp +// Three lines to wire everything up +builder.Services.AddMediator() + .AddDistributedQueues(opts => { opts.WorkersEnabled = options.IsWorkerEnabled; }) + .AddDistributedNotifications() + .UseAws(aws => aws.ServiceUrl = builder.Configuration["AWS:ServiceURL"]!) + .UseRedisJobState(); + +// Register your modules +builder.Services.AddCommonModule(); +builder.Services.AddOrdersModule(); +builder.Services.AddProductsModule(); +builder.Services.AddReportsModule(); + +// One line generates all API endpoints +app.MapMediatorEndpoints(); +``` + +> "That's it. `AddMediator()` discovers all handlers by naming convention. `AddDistributedQueues()` and `AddDistributedNotifications()` add the infrastructure. `MapMediatorEndpoints()` generates minimal API endpoints from every handler. Zero manual route registration." + +--- + +## Part 3: Handlers & Convention-Based Discovery (3 min) + +### Show: `Orders.Module/Handlers/OrderHandler.cs` + +Open [samples/CleanArchitectureSample/src/Orders.Module/Handlers/OrderHandler.cs](samples/CleanArchitectureSample/src/Orders.Module/Handlers/OrderHandler.cs) + +### Script + +> "Here's the Order handler. Notice there's no interface to implement, no base class to inherit. The class is named `OrderHandler` β€” the suffix `Handler` is enough for the source generator to discover it at compile time. +> +> Each method takes a message as its first parameter. The generator matches message types to methods automatically. Additional parameters like `IOrderRepository` and `CancellationToken` are resolved from DI β€” method-level injection, not just constructor injection." + +### Highlight: CreateOrder method + +```csharp +[Retry] +[HandlerAuthorize(Roles = ["User", "Admin"])] +public async Task<(Result, OrderCreated?)> HandleAsync( + CreateOrder command, + IOrderRepository repository, + CancellationToken cancellationToken) +``` + +> "Look at that return type β€” it's a tuple. The first element is the `Result` that goes back to the caller. The second is `OrderCreated?` β€” a cascading event that's automatically published after the handler completes. The question mark means it's optional: return `null` and it simply isn't published. The handler doesn't know or care who will react to `OrderCreated`. That's the beauty β€” complete decoupling." + +### Show: Products.Module UpdateProduct (multiple cascading events) + +Open [samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs](samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs) and find `UpdateProduct`: + +```csharp +public async Task<(Result, ProductUpdated?, ProductStockChanged?)> HandleAsync( + UpdateProduct command, ...) +``` + +> "Products takes it further β€” `UpdateProduct` returns *two* optional cascading events. `ProductStockChanged` is only published when the stock quantity actually changes. Null events are simply not published. This gives you precise, conditional event publishing with zero ceremony." + +--- + +## Part 4: Live Demo β€” Create & Observe Events (3 min) + +### Action: Open the Frontend + +Navigate to `https://localhost:5199` β€” the Dashboard page. + +> "Let's see this in action. I've got the SvelteKit frontend running. The dashboard shows aggregate stats β€” total orders, total products, total revenue β€” all fetched through the Reports module, which queries Orders and Products via the mediator." + +### Action: Open the Events Page + +Click **Events** in the navigation. Show the connection status indicator (green dot). + +> "This page shows a real-time event stream using Server-Sent Events. It's powered by a streaming handler that returns `IAsyncEnumerable` β€” the mediator's `SubscribeAsync` API yields every event as it's published anywhere in the system." + +### Action: Log in as Admin + +Go to **Login**, enter `admin`/`admin`. + +### Action: Create a Product + +Go to **Products** β†’ click **Create** β†’ fill in: +- Name: "Wireless Keyboard" +- Description: "Bluetooth mechanical keyboard" +- Price: 79.99 +- Stock: 100 + +Submit. + +### Observe + +> "Watch the Events page β€” " *(switch to it or have it open in a split)* + +Events should appear: +- `ProductCreated` (green badge) + +> "One form submission, and the event was published to *all* replicas via SNS. The audit handler logged it. The notification handler processed it. And the cache for product listings was invalidated. All automatically β€” the product handler just returned a tuple." + +### Action: Create an Order + +Go to **Orders** β†’ **Create** β†’ fill in: +- Customer ID: "customer-123" +- Amount: 49.99 +- Description: "Keyboard stand purchase" + +Submit. + +### Observe + +Events page shows: +- `OrderCreated` (green badge) + +> "Same pattern. The order handler returned `(Result, OrderCreated?)`, the event was published, and the audit and notification handlers picked it up asynchronously via SQS. The dashboard stats update automatically." + +### Action: Switch Back to Dashboard + +Click **Dashboard** β€” show updated totals reflecting the new order and product. + +> "The dashboard refreshes automatically when events arrive. The Reports module fetches fresh data from Orders and Products through the mediator β€” no direct module dependencies." + +--- + +## Part 5: Middleware Pipeline Deep Dive (3 min) + +### Show: ObservabilityMiddleware + +Open [samples/CleanArchitectureSample/src/Common.Module/Middleware/ObservabilityMiddleware.cs](samples/CleanArchitectureSample/src/Common.Module/Middleware/ObservabilityMiddleware.cs) + +### Script + +> "Every request flows through a middleware pipeline. This is the Observability middleware β€” it has three hooks: `Before`, `After`, and `Finally`. +> +> `Before` runs first and returns a `Stopwatch`. That return value is automatically passed as a parameter to `After` and `Finally` β€” that's state passing across the pipeline, without any manual wiring. +> +> `After` runs on success. `Finally` runs always β€” like a try/finally block. If the handler took more than 100ms, it logs a warning." + +### Show: ValidationMiddleware + +Open [samples/CleanArchitectureSample/src/Common.Module/Middleware/ValidationMiddleware.cs](samples/CleanArchitectureSample/src/Common.Module/Middleware/ValidationMiddleware.cs) + +> "Validation middleware checks data annotations on your messages β€” `[Required]`, `[Range]`, `[StringLength]`. If validation fails, it short-circuits: returns `Result.Invalid()` and the handler never executes." + +### Show: Message with Validation + +Open [samples/CleanArchitectureSample/src/Orders.Module/Messages/OrderMessages.cs](samples/CleanArchitectureSample/src/Orders.Module/Messages/OrderMessages.cs) + +```csharp +public record CreateOrder( + [Required] [StringLength(50, MinimumLength = 3)] string CustomerId, + [Required] [Range(0.01, 1000000)] decimal Amount, + [Required] [StringLength(200, MinimumLength = 5)] string Description +) +``` + +> "Standard .NET validation attributes on a plain record. The middleware picks them up automatically. No FluentValidation dependency, no validators to register." + +### Show: Middleware Ordering + +> "Middleware is ordered with declarative dependencies β€” `OrderBefore` and `OrderAfter` β€” instead of fragile magic numbers. The pipeline ends up being:" + +``` +RetryMiddleware (wraps everything) + └─ CachingMiddleware (cache-aside) + └─ ObservabilityMiddleware (logging + timing) + └─ ValidationMiddleware (short-circuit on invalid input) + └─ Module-scoped middleware + └─ Handler +``` + +--- + +## Part 6: Custom Attribute-Triggered Middleware (2 min) + +### Show: CachedAttribute and CachingMiddleware + +Open the `CachedAttribute` definition in Common.Module: + +```csharp +[UseMiddleware(typeof(CachingMiddleware))] +public sealed class CachedAttribute : Attribute +{ + public int DurationSeconds { get; set; } = 300; + public bool SlidingExpiration { get; set; } +} +``` + +### Script + +> "Here's something powerful. `[Cached]` and `[Retry]` aren't built into the framework β€” they're plain attributes you define yourself. The `[UseMiddleware]` meta-attribute links them to their middleware class. The middleware is marked `ExplicitOnly = true` so it only runs when the attribute is present. +> +> This means you can create your own cross-cutting concerns with the same pattern: define an attribute, point it at your middleware, and decorate any handler method. Zero configuration." + +### Show: Caching in Action + +Open ProductHandler and find `GetProductCatalog`: + +```csharp +[Cached(DurationSeconds = 60)] +public async Task> HandleAsync(GetProductCatalog query, ...) +{ + // Simulates 500ms expensive computation +} +``` + +> "This query simulates an expensive 500ms computation. With `[Cached(DurationSeconds = 60)]`, the first call takes 500ms, but every subsequent call returns instantly from the hybrid cache β€” in-memory L1 backed by Redis L2. On a cache miss, it hits L1, then L2, then finally executes the handler." + +### Action: Demo Caching (optional live) + +Call `GET /api/products/catalog` in the browser's Scalar UI (or watch logs): +- First call: ~500ms (check Aspire traces) +- Second call: ~1ms (served from cache) + +--- + +## Part 7: Authorization (1 min) + +### Script + +> "Authorization is built in. Each module sets `AuthorizationRequired = true` at the assembly level β€” every handler requires auth by default. Individual handlers opt out with `[HandlerAllowAnonymous]` for public endpoints like health checks and product listings. Sensitive operations get role-based access with `[HandlerAuthorize(Roles = ["Admin"])]`." + +### Show: Contrast Anonymous vs Authorized + +```csharp +// Public β€” no auth required +[HandlerAllowAnonymous] +public async Task> HandleAsync(GetProduct query, ...) + +// Admin + Manager only +[HandlerAuthorize(Roles = ["Admin", "Manager"])] +public async Task<(Result, ProductCreated?)> HandleAsync(CreateProduct command, ...) + +// Admin only +[HandlerAuthorize(Roles = ["Admin"])] +public async Task<(Result, ProductDeleted?)> HandleAsync(DeleteProduct command, ...) +``` + +> "When unauthorized, the handler isn't invoked β€” the generated code returns `Result.Unauthorized()` or `Result.Forbidden()` before the pipeline even starts. This is enforced at compile time in the generated interceptors, so there's zero performance overhead." + +--- + +## Part 8: Endpoint Generation (2 min) + +### Show: Scalar API Reference + +Navigate to the Scalar API docs (find URL from Aspire Dashboard, typically at `/scalar/v1`). + +### Script + +> "I didn't write a single API route. Every endpoint you see here was generated by the source generator from the handler methods. It infers the HTTP method from the message name: `Get*` β†’ GET, `Create*` β†’ POST, `Update*` β†’ PUT, `Delete*` β†’ DELETE. The route is inferred from the endpoint group and parameter names." + +### Show: Generated Route Examples + +| Handler Method | Generated Endpoint | +|---|---| +| `HandleAsync(GetOrders)` | `GET /api/orders` | +| `HandleAsync(GetOrder)` | `GET /api/orders/{orderId}` | +| `HandleAsync(CreateOrder)` | `POST /api/orders` | +| `HandleAsync(UpdateOrder)` | `PUT /api/orders/{orderId}` | +| `HandleAsync(DeleteOrder)` | `DELETE /api/orders/{orderId}` | +| `HandleAsync(GetDashboardReport)` | `GET /api/reports` | +| `HandleAsync(SearchCatalog)` | `GET /api/reports/search-catalog` | + +> "Result types map to HTTP status codes automatically. `Result.Ok()` β†’ 200, `Result.Created()` β†’ 201, `Result.NotFound()` β†’ 404, `Result.Invalid()` β†’ 422, `Result.Unauthorized()` β†’ 401. No manual `Results.Ok()` or `Results.NotFound()` wrapping." + +### Show: Endpoint Group + Filter + +```csharp +[HandlerEndpointGroup("Orders", EndpointFilters = [typeof(SetRequestedByFilter)])] +public class OrderHandler(IOrderRepository repository) { ... } +``` + +> "Endpoint groups control the route prefix and let you attach endpoint filters β€” these are ASP.NET Core endpoint filters, not mediator middleware. `SetRequestedByFilter` reads the authenticated user from `HttpContext` and populates a `RequestedBy` property on messages that implement `IHasRequestedBy`." + +--- + +## Part 9: Real-Time Streaming (1 min) + +### Show: ClientEventStreamHandler + +Open [samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs](samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs) + +```csharp +[HandlerEndpoint(Streaming = EndpointStreaming.ServerSentEvents)] +public async IAsyncEnumerable Handle( + GetEventStream message, + [EnumeratorCancellation] CancellationToken cancellationToken) +{ + await foreach (var evt in mediator.SubscribeAsync( + cancellationToken: cancellationToken)) + { + yield return new ClientEvent(evt.GetType().Name, evt); + } +} +``` + +### Script + +> "This is the entire streaming handler. It returns `IAsyncEnumerable` and the `ServerSentEvents` attribute tells the endpoint generator to use `TypedResults.ServerSentEvents()`. The mediator's `SubscribeAsync` API yields every notification matching `IDispatchToClient` as it's published β€” from any handler, any module. +> +> The browser connects with `EventSource('/api/events/stream')` and gets a live feed of every domain event. That's what powers the real-time updates on the dashboard, the event log page, and the toast notifications." + +### Action: Show Events Page + +Switch to `https://localhost:5199/events` β€” show events streaming in real-time as you perform actions. + +--- + +## Part 10: Distributed Features β€” The New Stuff (5 min) + +### Script: Architecture Overview + +> "Now let's talk about the new distributed capabilities. In Aspire, we have 3 API replicas and 3 worker replicas β€” separate processes. The API replicas serve HTTP traffic and enqueue work. The worker replicas process queues. They share the same codebase but run in different modes. +> +> Two patterns: **Distributed Queues** for work offload, and **Distributed Notifications** for event fan-out." + +### Show: Aspire Dashboard + +Open the Aspire Dashboard and show: +- 3 `api-0`, `api-1`, `api-2` replicas +- 3 `worker-0`, `worker-1`, `worker-2` replicas +- `localstack` container (SQS + SNS) +- `redis` container + +### Show: AppHost/Program.cs + +Open [samples/CleanArchitectureSample/src/AppHost/Program.cs](samples/CleanArchitectureSample/src/AppHost/Program.cs) + +> "The AppHost defines the topology. Three API replicas with `--mode api`. Three worker replicas with `--mode worker`. LocalStack provides SQS and SNS. Redis stores job state and serves as the L2 cache. All wired through Aspire resource references." + +--- + +### 10a: Distributed Queues β€” Async Processing + +### Show: AuditEventHandler with [Queue] + +Open [samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs](samples/CleanArchitectureSample/src/Common.Module/Handlers/AuditEventHandler.cs) + +```csharp +[Queue] +public class AuditEventHandler(IAuditService auditService, ILogger logger) +{ + public async Task HandleAsync(OrderCreated evt, CancellationToken cancellationToken) + { + await auditService.LogAsync(new AuditEntry("OrderCreated", evt.OrderId, ...)); + } +} +``` + +### Script + +> "Adding `[Queue]` to a handler class is all it takes to make it asynchronous. The handler code itself is identical β€” no queue-specific logic. When `OrderCreated` is published, the queue middleware serializes the message, enqueues it to SQS, and returns immediately. The worker replicas β€” running in a different process β€” pick it up and execute the same handler pipeline with all the middleware. +> +> The handler doesn't know or care whether it's running inline or from a queue. Your business logic stays clean." + +### Show: Queue Configuration Options + +> "The `[Queue]` attribute has a rich configuration surface:" + +```csharp +[Queue( + Concurrency = 5, // 5 parallel consumers + MaxAttempts = 3, // 1 try + 2 retries + TimeoutSeconds = 30, // Visibility timeout + RetryPolicy = QueueRetryPolicy.Exponential, + TrackProgress = true // Enable job state tracking +)] +``` + +> "'RetryPolicy' supports none, fixed delay, and exponential backoff with jitter to prevent thundering herd. Messages that exceed max attempts are dead-lettered with full context β€” original headers, failure reason, timestamps." + +--- + +### 10b: Job Progress Tracking + +### Show: DemoExportJobHandler + +Open [samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs](samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs) + +```csharp +[Queue(TrackProgress = true, Concurrency = 5, TimeoutSeconds = 10)] +public class DemoExportJobHandler(ILogger logger) +{ + public async Task HandleAsync( + DemoExportJob message, + QueueContext queueContext, + CancellationToken ct) + { + for (int i = 1; i <= message.Steps; i++) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(message.StepDelayMs, ct); + + int percent = (int)((double)i / message.Steps * 100); + await queueContext.ReportProgressAsync(percent, $"Step {i}/{message.Steps}"); + } + + return Result.Ok(); + } +} +``` + +### Script + +> "With `TrackProgress = true`, the worker tracks the full lifecycle of each job in Redis β€” queued, processing, progress percentage, completed, failed, or cancelled. `QueueContext` is injected as a handler parameter, giving you `ReportProgressAsync()` for live progress updates. +> +> Let's see it live." + +### Action: Open Queue Dashboard + +Navigate to `https://localhost:5199/queues`. + +> "This is the queue dashboard β€” entirely built from mediator endpoints in `QueueDashboardHandler`. It shows every registered queue worker, real-time throughput sparklines, and job state." + +### Action: Enqueue Demo Jobs + +Click **Enqueue 10 Jobs** button. + +> "Watch the active jobs section β€” each job shows a progress bar that updates in real-time as the worker reports progress. Each job has a unique ID, start time, elapsed duration, and the current step message." + +**Observe:** +- Jobs appearing in "Active" with progress bars filling +- Progress messages updating: "Step 3/10", "Step 7/10" +- Jobs completing and moving to "Recent" section with green status +- Some jobs failing (simulated transient errors) and being retried +- Rare critical errors going to dead letter + +> "The throughput sparklines update live β€” green for processed, red for failed, orange for dead-lettered. You can see the workers processing across all replicas." + +### Action: Cancel a Job + +Find an active job and click the **Cancel** button. + +> "Cancellation is cooperative. The dashboard requests cancellation through the job state store in Redis. The worker polls for cancellation every 5 seconds β€” or on every progress report. When detected, it fires the handler's CancellationToken, the handler observes it, and the job is marked as cancelled. No force-kill." + +**Observe:** Job status changes to "Cancelled" (yellow/orange badge). + +--- + +### 10c: Distributed Notifications β€” Event Fan-Out + +### Show: Domain Events with IDistributedNotification + +Open [samples/CleanArchitectureSample/src/Common.Module/Events/DomainEvents.cs](samples/CleanArchitectureSample/src/Common.Module/Events/DomainEvents.cs) + +```csharp +public record OrderCreated(string OrderId, string CustomerId, decimal Amount, DateTime CreatedAt) + : IDistributedNotification, IDispatchToClient; +``` + +### Script + +> "`IDistributedNotification` is a marker interface. When an event implements it, the `DistributedNotificationWorker` intercepts the local publish and broadcasts it to all replicas via SNS. Every replica gets its own SQS subscription queue β€” SNS fans out to all of them. +> +> Two layers prevent infinite loops: the HostId header skips self-delivery, and a reference identity set prevents re-broadcasting messages received from the bus. +> +> This is what makes the SSE stream work across replicas. If Replica 1 handles the request and publishes `OrderCreated`, Replicas 2 and 3 also receive it β€” so every connected browser gets the real-time update regardless of which replica its SSE connection is on." + +### Action: Demonstrate Multi-Replica Fan-Out + +1. In the Aspire Dashboard, open logs for two different API replicas +2. Create an order in the frontend +3. Show that `OrderCreated` appears in the logs/traces of ALL replicas + +> "One replica handled the HTTP request, but all three received the event. That's distributed notifications in action." + +--- + +### 10d: Cache Invalidation Across Replicas + +### Show: ProductCacheInvalidationHandler + +Open [samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs](samples/CleanArchitectureSample/src/Products.Module/Handlers/ProductHandler.cs) β€” find the cache invalidation handler (or separate file). + +### Script + +> "When a product is updated on Replica 1, `ProductUpdated` fans out via SNS to all replicas. Each replica runs `ProductCacheInvalidationHandler`, which explicitly invalidates the affected cache entries. The hybrid cache has an in-memory L1 layer per-replica and a shared Redis L2 layer β€” the invalidation handler clears both." + +> "Without distributed notifications, Replica 2's L1 cache would serve stale data until TTL expiry. With this pattern, cache coherence is immediate." + +--- + +## Part 11: Cross-Module Communication via Mediator (1 min) + +### Show: ReportHandler + +Open [samples/CleanArchitectureSample/src/Reports.Module/Handlers/ReportHandler.cs](samples/CleanArchitectureSample/src/Reports.Module/Handlers/ReportHandler.cs) β€” find `GetDashboardReport`: + +```csharp +public async Task> HandleAsync(GetDashboardReport query, CancellationToken ct) +{ + var ordersResult = await mediator.InvokeAsync(new GetOrders(), ct); + var productsResult = await mediator.InvokeAsync(new GetProducts(), ct); + // ... aggregate and return +} +``` + +### Script + +> "The Reports module has no direct dependency on Orders or Products internals. It sends messages through the mediator β€” the same way an HTTP client would call an API. This is what makes a modular monolith work: the modules are independent, and you could extract any module into a separate service by replacing the mediator call with an HTTP call. The message contracts are the boundary." + +--- + +## Part 12: Aspire Observability (1 min) + +### Show: Aspire Dashboard β€” Traces + +Open the Aspire Dashboard traces view. Create an order and find the trace. + +### Script + +> "Because the mediator integrates with OpenTelemetry, every handler invocation creates a span. Distributed notifications propagate the W3C trace context across replicas, so you can follow an event from the original request through SNS to all replicas in a single distributed trace. +> +> Queue workers also propagate trace context β€” a job enqueued by an API replica carries its trace ID through SQS to the worker replica. The entire request lifecycle is visible in one trace." + +### Show: Distributed Trace + +Find a trace that shows: +- HTTP request on API replica +- Handler execution +- Event publish to SNS +- Event received on other replicas +- Queue enqueue + dequeue on worker + +--- + +## Part 13: Recap & Close (1 min) + +### Script + +> "Let's recap what we've seen: +> +> **Zero boilerplate** β€” plain handler classes, discovered by naming convention, no interfaces or base classes. +> +> **Near-direct-call performance** β€” 3.5 nanosecond command dispatch via source generators and interceptors. +> +> **Rich middleware pipeline** β€” observability, validation, caching, retry β€” all composable with state passing, short-circuiting, and custom attributes. +> +> **Auto-generated API endpoints** β€” handlers become minimal API routes with Result-to-HTTP status mapping. +> +> **Cascading events** β€” tuple returns for decoupled, event-driven architectures. +> +> **Real-time streaming** β€” `IAsyncEnumerable` handlers for Server-Sent Events. +> +> **Distributed queues** β€” `[Queue]` for async processing with retry, dead-lettering, progress tracking, and cancellation. +> +> **Distributed notifications** β€” `IDistributedNotification` for event fan-out across replicas, enabling real-time features and cache coherence. +> +> **Full observability** β€” OpenTelemetry traces that follow messages across replicas, queues, and pub/sub. +> +> All of this from a library that generates everything at compile time. Your handler code stays simple. The complexity is handled by the source generator. +> +> Check out the docs at foundatio.dev, and the sample app is in the GitHub repo under `samples/CleanArchitectureSample`. Thanks for watching." + +--- + +## Appendix: Backup Demo Scenarios + +### If Something Goes Wrong with Aspire + +Run the API standalone without distributed infrastructure: + +```bash +cd samples/CleanArchitectureSample/src/Api +dotnet run +``` + +This uses in-memory queues and pub/sub β€” everything still works, just single-process. + +### Payment Retry Demo + +> "The payment handler simulates transient failures β€” 60% of first attempts fail. With `[Retry(MaxAttempts = 5, DelayMs = 100)]`, it automatically retries with exponential backoff until it succeeds." + +1. Create an order +2. Process a payment (via API) +3. Watch logs show retry attempts succeeding on attempt 2 or 3 + +### Validation Short-Circuit Demo + +Try creating an order with invalid data: +- Empty customer ID +- Negative amount +- Description too short + +Show the 422 response with validation errors β€” handler never executed. + +### Caching Performance Demo + +1. Call `GET /api/products/catalog` β€” note the ~500ms response (Aspire trace) +2. Call again β€” note the ~1ms response (cache hit) +3. Update a product β€” cache invalidated +4. Call again β€” ~500ms (cache miss, recomputed) + +### Dead Letter Demo + +If a queue job fails with `Result.CriticalError()`, it's immediately dead-lettered. Show the dead-letter count in the queue dashboard. diff --git a/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj b/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj index f950ddea..2d75324f 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj +++ b/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj @@ -1,5 +1,6 @@ + net10.0 diff --git a/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj b/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj index a1c0f3aa..cdc44fcf 100644 --- a/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj +++ b/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj @@ -1,5 +1,6 @@ + net10.0 diff --git a/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj index a2b1cc5f..80589b85 100644 --- a/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj +++ b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj @@ -1,5 +1,6 @@ + net10.0 From b6015c214fec362dfd72d465bc54cdbda055d368 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 9 Apr 2026 15:12:05 -0500 Subject: [PATCH 26/27] Progress and updates --- .../Foundatio.Mediator.Benchmarks.csproj | 8 +- docs/guide/distributed-transports.md | 23 +-- docs/package.json | 6 +- .../src/Api/Api.csproj | 6 +- .../src/AppHost/AppHost.csproj | 8 +- .../src/Common.Module/Common.Module.csproj | 2 +- .../Handlers/QueueDashboardHandler.cs | 43 ++--- .../src/Orders.Module/Orders.Module.csproj | 2 +- .../Products.Module/Products.Module.csproj | 2 +- .../ServiceDefaults/ServiceDefaults.csproj | 12 +- .../src/Web/package.json | 6 +- .../Foundatio.Mediator.Abstractions.csproj | 2 +- .../Foundatio.Mediator.Distributed.Aws.csproj | 4 +- .../SqsPubSubClient.cs | 33 ++-- .../SqsQueueClient.cs | 150 ++++++++---------- ...oundatio.Mediator.Distributed.Redis.csproj | 2 +- .../DistributedInfrastructureInitializer.cs | 4 +- .../DistributedNotificationWorker.cs | 2 +- .../DistributedServiceExtensions.cs | 4 +- .../Foundatio.Mediator.Distributed.csproj | 2 +- .../IPubSubClient.cs | 9 +- .../IQueueClient.cs | 18 +-- .../InMemoryPubSubClient.cs | 25 +-- .../InMemoryQueueClient.cs | 61 +++---- .../QueueClientBase.cs | 23 +-- .../QueueDefinition.cs | 15 ++ .../QueueMiddleware.cs | 2 +- .../TopicDefinition.cs | 15 ++ ...atio.Mediator.Distributed.Aws.Tests.csproj | 8 +- .../SqsPubSubClientTests.cs | 14 +- .../SqsQueueClientTests.cs | 26 +-- ...io.Mediator.Distributed.Redis.Tests.csproj | 10 +- ...DistributedNotificationIntegrationTests.cs | 4 +- ...oundatio.Mediator.Distributed.Tests.csproj | 4 +- .../InMemoryPubSubClientTests.cs | 16 +- .../InMemoryQueueClientTests.cs | 32 ++-- .../QueueClientTestBase.cs | 28 ++-- .../Foundatio.Mediator.Tests.csproj | 2 +- 38 files changed, 322 insertions(+), 311 deletions(-) create mode 100644 src/Foundatio.Mediator.Distributed/QueueDefinition.cs create mode 100644 src/Foundatio.Mediator.Distributed/TopicDefinition.cs diff --git a/benchmarks/Foundatio.Mediator.Benchmarks/Foundatio.Mediator.Benchmarks.csproj b/benchmarks/Foundatio.Mediator.Benchmarks/Foundatio.Mediator.Benchmarks.csproj index 385be0fe..52b8f5c7 100644 --- a/benchmarks/Foundatio.Mediator.Benchmarks/Foundatio.Mediator.Benchmarks.csproj +++ b/benchmarks/Foundatio.Mediator.Benchmarks/Foundatio.Mediator.Benchmarks.csproj @@ -32,12 +32,12 @@ - - + + - - + + diff --git a/docs/guide/distributed-transports.md b/docs/guide/distributed-transports.md index d926fe37..1cbb3935 100644 --- a/docs/guide/distributed-transports.md +++ b/docs/guide/distributed-transports.md @@ -168,22 +168,23 @@ All state transitions are atomic operations. Implement `IQueueClient` for custom queue transports and `IPubSubClient` for custom pub/sub transports: ```csharp -public interface IQueueClient +public interface IQueueClient : IAsyncDisposable { - Task SendAsync(string queueName, QueueMessage message, CancellationToken ct = default); - Task> ReceiveAsync(string queueName, int maxCount, TimeSpan? visibilityTimeout, CancellationToken ct = default); - Task CompleteAsync(string queueName, QueueMessage message, CancellationToken ct = default); - Task AbandonAsync(string queueName, QueueMessage message, TimeSpan? delay = null, CancellationToken ct = default); - Task DeadLetterAsync(string queueName, QueueMessage message, string reason, CancellationToken ct = default); - Task RenewTimeoutAsync(string queueName, QueueMessage message, TimeSpan extension, CancellationToken ct = default); - Task EnsureQueueAsync(string queueName, CancellationToken ct = default); + Task SendAsync(string queueName, IReadOnlyList entries, CancellationToken ct = default); + Task> ReceiveAsync(string queueName, int maxCount, CancellationToken ct = default); + Task CompleteAsync(QueueMessage message, CancellationToken ct = default); + Task AbandonAsync(QueueMessage message, TimeSpan delay = default, CancellationToken ct = default); + Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken ct = default); + Task RenewTimeoutAsync(QueueMessage message, TimeSpan extension, CancellationToken ct = default); + Task EnsureQueuesAsync(IReadOnlyList queues, CancellationToken ct = default); + Task> GetQueueStatsAsync(IReadOnlyList queueNames, CancellationToken ct = default); } -public interface IPubSubClient +public interface IPubSubClient : IAsyncDisposable { - Task PublishAsync(string topic, PubSubMessage message, CancellationToken ct = default); + Task PublishAsync(string topic, IReadOnlyList messages, CancellationToken ct = default); Task SubscribeAsync(string topic, Func handler, CancellationToken ct = default); - Task EnsureTopicsAsync(IEnumerable topics, CancellationToken ct = default); + Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken ct = default); } ``` diff --git a/docs/package.json b/docs/package.json index 33d44263..c67636e3 100644 --- a/docs/package.json +++ b/docs/package.json @@ -10,9 +10,9 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "mermaid": "^11.13.0", - "vitepress": "^2.0.0-alpha.16", - "vitepress-plugin-llms": "^1.11.0", + "mermaid": "^11.14.0", + "vitepress": "^2.0.0-alpha.17", + "vitepress-plugin-llms": "^1.12.0", "vitepress-plugin-mermaid": "^2.0.17" }, "overrides": { diff --git a/samples/CleanArchitectureSample/src/Api/Api.csproj b/samples/CleanArchitectureSample/src/Api/Api.csproj index 98bb450b..f1ee52fd 100644 --- a/samples/CleanArchitectureSample/src/Api/Api.csproj +++ b/samples/CleanArchitectureSample/src/Api/Api.csproj @@ -34,9 +34,9 @@ - - - + + + diff --git a/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj b/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj index 387319b7..4fa186e5 100644 --- a/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj +++ b/samples/CleanArchitectureSample/src/AppHost/AppHost.csproj @@ -1,6 +1,6 @@ - + Exe @@ -13,9 +13,9 @@ - - - + + + diff --git a/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj b/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj index 0ad1ffdb..536fbe09 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj +++ b/samples/CleanArchitectureSample/src/Common.Module/Common.Module.csproj @@ -28,7 +28,7 @@ - + diff --git a/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs index 55d2db40..ed64adba 100644 --- a/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs @@ -39,12 +39,23 @@ public async Task>> HandleAsync(GetQueues query, Cance { var workers = _registry.GetWorkers(); - // Fetch all queue stats in parallel β€” each is an independent SQS call. + if (_infraReady is not null) + await _infraReady.WaitAsync(ct).ConfigureAwait(false); + + // Batch-fetch stats for all queues in a single call. + var queueNames = workers.Select(w => w.QueueName).ToList(); + IReadOnlyList allStats = []; + try { allStats = await _queueClient.GetQueueStatsAsync(queueNames, ct).ConfigureAwait(false); } + catch { /* Transport may not support stats */ } + + var statsMap = allStats.ToDictionary(s => s.QueueName); + var tasks = new Task[workers.Count]; for (int i = 0; i < workers.Count; i++) { var worker = workers[i]; - tasks[i] = BuildSummaryAsync(worker, ct); + statsMap.TryGetValue(worker.QueueName, out var stats); + tasks[i] = ToSummaryAsync(worker, stats, ct); } var results = await Task.WhenAll(tasks).ConfigureAwait(false); @@ -58,7 +69,18 @@ public async Task> HandleAsync(GetQueue query, Cancellation if (worker is null) return Result.NotFound($"Queue worker '{query.QueueName}' not found"); - return await BuildSummaryAsync(worker, ct).ConfigureAwait(false); + if (_infraReady is not null) + await _infraReady.WaitAsync(ct).ConfigureAwait(false); + + QueueStats? stats = null; + try + { + var statsList = await _queueClient.GetQueueStatsAsync([query.QueueName], ct).ConfigureAwait(false); + stats = statsList.FirstOrDefault(); + } + catch { /* Transport may not support stats */ } + + return await ToSummaryAsync(worker, stats, ct).ConfigureAwait(false); } public async Task> HandleAsync(GetJobDashboard query, CancellationToken ct) @@ -145,21 +167,6 @@ public async Task> HandleAsync(EnqueueDemoJob command, I return new DemoJobEnqueued(lastJobId ?? string.Empty); } - private async Task BuildSummaryAsync(QueueWorkerInfo worker, CancellationToken ct) - { - // Wait for queues to be created before hitting SQS for stats. - // Without this, cold-start dashboard requests trigger slow/failing - // GetQueueAttributes calls for queues that don't exist yet. - if (_infraReady is not null) - await _infraReady.WaitAsync(ct).ConfigureAwait(false); - - QueueStats? stats = null; - try { stats = await _queueClient.GetQueueStatsAsync(worker.QueueName, ct).ConfigureAwait(false); } - catch { /* Transport may not support stats */ } - - return await ToSummaryAsync(worker, stats, ct).ConfigureAwait(false); - } - private async Task ToSummaryAsync(QueueWorkerInfo worker, QueueStats? stats, CancellationToken ct) { QueueCounterStats? counterStats = null; diff --git a/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj b/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj index cc8529fc..e1a69d38 100644 --- a/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj +++ b/samples/CleanArchitectureSample/src/Orders.Module/Orders.Module.csproj @@ -22,7 +22,7 @@ - + diff --git a/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj b/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj index 9ee7ed14..a6e32a5b 100644 --- a/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj +++ b/samples/CleanArchitectureSample/src/Products.Module/Products.Module.csproj @@ -22,7 +22,7 @@ - + diff --git a/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj b/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj index e1d56422..8223b0bb 100644 --- a/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj @@ -13,14 +13,14 @@ - + - - - + + + - - + + diff --git a/samples/CleanArchitectureSample/src/Web/package.json b/samples/CleanArchitectureSample/src/Web/package.json index 246d6a14..d38009d8 100644 --- a/samples/CleanArchitectureSample/src/Web/package.json +++ b/samples/CleanArchitectureSample/src/Web/package.json @@ -13,18 +13,18 @@ "devDependencies": { "@foundatiofx/fetchclient": "^1.3.3", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.55.0", + "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.0", "clsx": "^2.1.1", "lucide-svelte": "^1.0.1", - "svelte": "^5.55.1", + "svelte": "^5.55.2", "svelte-check": "^4.4.6", "tailwind-merge": "^3.0.0", "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", - "vite": "^8.0.3" + "vite": "^8.0.8" } } diff --git a/src/Foundatio.Mediator.Abstractions/Foundatio.Mediator.Abstractions.csproj b/src/Foundatio.Mediator.Abstractions/Foundatio.Mediator.Abstractions.csproj index 112376e2..fe3292fa 100644 --- a/src/Foundatio.Mediator.Abstractions/Foundatio.Mediator.Abstractions.csproj +++ b/src/Foundatio.Mediator.Abstractions/Foundatio.Mediator.Abstractions.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj b/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj index 2d75324f..04ce5e42 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj +++ b/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs index e1958ffe..8eb2c4e9 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs @@ -43,24 +43,27 @@ public SqsPubSubClient( } /// - public async Task PublishAsync(string topic, PubSubEntry message, CancellationToken cancellationToken = default) + public async Task PublishAsync(string topic, IReadOnlyList messages, CancellationToken cancellationToken = default) { var topicArn = await GetOrCreateTopicArnAsync(topic, cancellationToken).ConfigureAwait(false); - // Wrap body + headers into a single JSON envelope for SNS - var envelope = new MessageEnvelope + foreach (var message in messages) { - Body = Convert.ToBase64String(message.Body.Span), - Headers = message.Headers is not null ? new Dictionary(message.Headers) : null - }; + // Wrap body + headers into a single JSON envelope for SNS + var envelope = new MessageEnvelope + { + Body = Convert.ToBase64String(message.Body.Span), + Headers = message.Headers is not null ? new Dictionary(message.Headers) : null + }; - var json = JsonSerializer.Serialize(envelope); + var json = JsonSerializer.Serialize(envelope); - await _sns.PublishAsync(new PublishRequest - { - TopicArn = topicArn, - Message = json - }, cancellationToken).ConfigureAwait(false); + await _sns.PublishAsync(new PublishRequest + { + TopicArn = topicArn, + Message = json + }, cancellationToken).ConfigureAwait(false); + } } /// @@ -280,7 +283,7 @@ private async Task GetOrCreateTopicArnAsync(string topic, CancellationTo } /// - public async Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) + public async Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) { if (topics.Count == 0) return; @@ -289,7 +292,7 @@ public async Task EnsureTopicsAsync(IReadOnlyList topics, CancellationTo // 1. Create the shared per-node SQS queue and all SNS topics in parallel var queueTask = EnsureSharedQueueAsync(cancellationToken); - var topicTasks = topics.Select(t => GetOrCreateTopicArnAsync(t, cancellationToken)).ToArray(); + var topicTasks = topics.Select(t => GetOrCreateTopicArnAsync(t.Name, cancellationToken)).ToArray(); await Task.WhenAll(topicTasks).ConfigureAwait(false); var queue = await queueTask.ConfigureAwait(false); @@ -327,7 +330,7 @@ await _sqs.SetQueueAttributesAsync(new SetQueueAttributesRequest _logger.LogInformation("EnsureTopics: policy set in {ElapsedMs}ms", sw.ElapsedMilliseconds); // 3. Subscribe the queue to all topics in parallel - await Task.WhenAll(topics.Select(topic => EnsureSubscriptionSetupAsync(topic, cancellationToken))).ConfigureAwait(false); + await Task.WhenAll(topics.Select(topic => EnsureSubscriptionSetupAsync(topic.Name, cancellationToken))).ConfigureAwait(false); _logger.LogInformation("EnsureTopics: complete in {ElapsedMs}ms ({Count} topics)", sw.ElapsedMilliseconds, topics.Count); } diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs index db98aca9..91275b4d 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs @@ -33,35 +33,7 @@ public SqsQueueClient(IAmazonSQS sqs, SqsQueueClientOptions? options = null, Tim /// private const int MaxSqsMessageAttributes = 10; - public async Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default) - { - var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); - - ValidateHeaderCount(entry.Headers, queueName); - - var request = new SendMessageRequest - { - QueueUrl = queueUrl, - MessageBody = Convert.ToBase64String(entry.Body.Span), - MessageAttributes = new Dictionary() - }; - - if (entry.Headers is { Count: > 0 }) - { - foreach (var (key, value) in entry.Headers) - { - request.MessageAttributes[key] = new MessageAttributeValue - { - DataType = "String", - StringValue = value - }; - } - } - - await _sqs.SendMessageAsync(request, cancellationToken).ConfigureAwait(false); - } - - public async Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) + public async Task SendAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) { if (entries.Count == 0) return; @@ -220,7 +192,7 @@ public async Task DeadLetterAsync(QueueMessage message, string reason, Cancellat }; // Send to DLQ then complete the original message - await SendAsync(dlqName, entry, cancellationToken).ConfigureAwait(false); + await SendAsync(dlqName, [entry], cancellationToken).ConfigureAwait(false); _dlqNotFound.TryRemove(dlqName, out _); await CompleteAsync(message, cancellationToken).ConfigureAwait(false); } @@ -291,81 +263,87 @@ private async Task GetQueueUrlAsync(string queueName, CancellationToken } /// - public async Task EnsureQueuesAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + public async Task EnsureQueuesAsync(IReadOnlyList queues, CancellationToken cancellationToken = default) { var sw = System.Diagnostics.Stopwatch.StartNew(); - await Task.WhenAll(queueNames.Select(name => GetQueueUrlAsync(name, cancellationToken))).ConfigureAwait(false); + await Task.WhenAll(queues.Select(q => GetQueueUrlAsync(q.Name, cancellationToken))).ConfigureAwait(false); - _logger.LogInformation("EnsureQueues: {Count} queues ready in {ElapsedMs}ms", queueNames.Count, sw.ElapsedMilliseconds); + _logger.LogInformation("EnsureQueues: {Count} queues ready in {ElapsedMs}ms", queues.Count, sw.ElapsedMilliseconds); } /// - public async Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) + public async Task> GetQueueStatsAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) { - var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); - - var response = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest + var results = new List(queueNames.Count); + foreach (var queueName in queueNames) { - QueueUrl = queueUrl, - AttributeNames = ["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"] - }, cancellationToken).ConfigureAwait(false); + var queueUrl = await GetQueueUrlAsync(queueName, cancellationToken).ConfigureAwait(false); - long activeCount = 0; - if (response.Attributes.TryGetValue("ApproximateNumberOfMessages", out var activeStr) - && long.TryParse(activeStr, out var parsedActive)) - activeCount = parsedActive; - - long inFlightCount = 0; - if (response.Attributes.TryGetValue("ApproximateNumberOfMessagesNotVisible", out var inFlightStr) - && long.TryParse(inFlightStr, out var parsedInFlight)) - inFlightCount = parsedInFlight; - - // Try to get dead-letter queue stats (DLQ is created lazily on first dead-letter) - long deadLetterCount = 0; - var dlqName = $"{queueName}-dead-letter"; - // Skip lookup if we already know the DLQ doesn't exist. - // The negative cache is cleared when DeadLetterAsync creates the queue. - if (!_dlqNotFound.ContainsKey(dlqName)) - { - try + var response = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest { - string dlqUrl; - if (_queueUrlCache.TryGetValue(dlqName, out var cachedDlqUrl)) + QueueUrl = queueUrl, + AttributeNames = ["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"] + }, cancellationToken).ConfigureAwait(false); + + long activeCount = 0; + if (response.Attributes.TryGetValue("ApproximateNumberOfMessages", out var activeStr) + && long.TryParse(activeStr, out var parsedActive)) + activeCount = parsedActive; + + long inFlightCount = 0; + if (response.Attributes.TryGetValue("ApproximateNumberOfMessagesNotVisible", out var inFlightStr) + && long.TryParse(inFlightStr, out var parsedInFlight)) + inFlightCount = parsedInFlight; + + // Try to get dead-letter queue stats (DLQ is created lazily on first dead-letter) + long deadLetterCount = 0; + var dlqName = $"{queueName}-dead-letter"; + // Skip lookup if we already know the DLQ doesn't exist. + // The negative cache is cleared when DeadLetterAsync creates the queue. + if (!_dlqNotFound.ContainsKey(dlqName)) + { + try { - dlqUrl = cachedDlqUrl; + string dlqUrl; + if (_queueUrlCache.TryGetValue(dlqName, out var cachedDlqUrl)) + { + dlqUrl = cachedDlqUrl; + } + else + { + var dlqResponse = await _sqs.GetQueueUrlAsync(new GetQueueUrlRequest { QueueName = dlqName }, cancellationToken).ConfigureAwait(false); + dlqUrl = dlqResponse.QueueUrl; + _queueUrlCache[dlqName] = dlqUrl; + } + + var dlqAttrs = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest + { + QueueUrl = dlqUrl, + AttributeNames = ["ApproximateNumberOfMessages"] + }, cancellationToken).ConfigureAwait(false); + + if (dlqAttrs.Attributes.TryGetValue("ApproximateNumberOfMessages", out var dlqStr) + && long.TryParse(dlqStr, out var parsedDlq)) + deadLetterCount = parsedDlq; } - else + catch { - var dlqResponse = await _sqs.GetQueueUrlAsync(new GetQueueUrlRequest { QueueName = dlqName }, cancellationToken).ConfigureAwait(false); - dlqUrl = dlqResponse.QueueUrl; - _queueUrlCache[dlqName] = dlqUrl; + // DLQ doesn't exist yet β€” remember so we don't retry on every poll + _dlqNotFound[dlqName] = true; } - - var dlqAttrs = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest - { - QueueUrl = dlqUrl, - AttributeNames = ["ApproximateNumberOfMessages"] - }, cancellationToken).ConfigureAwait(false); - - if (dlqAttrs.Attributes.TryGetValue("ApproximateNumberOfMessages", out var dlqStr) - && long.TryParse(dlqStr, out var parsedDlq)) - deadLetterCount = parsedDlq; } - catch + + results.Add(new QueueStats { - // DLQ doesn't exist yet β€” remember so we don't retry on every poll - _dlqNotFound[dlqName] = true; - } + QueueName = queueName, + ActiveCount = activeCount, + InFlightCount = inFlightCount, + DeadLetterCount = deadLetterCount + }); } - return new QueueStats - { - QueueName = queueName, - ActiveCount = activeCount, - InFlightCount = inFlightCount, - DeadLetterCount = deadLetterCount - }; + return results; } private static Message GetNativeMessage(QueueMessage message) diff --git a/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj b/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj index cdc44fcf..cec91da0 100644 --- a/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj +++ b/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs b/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs index bd18ccac..0749e2fc 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs @@ -122,6 +122,6 @@ private async Task WaitWithCancellationAsync(CancellationToken cancellationToken /// internal sealed class DistributedInfrastructureOptions { - public List QueueNames { get; } = []; - public List TopicNames { get; } = []; + public List QueueNames { get; } = []; + public List TopicNames { get; } = []; } diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs index bed6018b..7414e11a 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs @@ -149,7 +149,7 @@ private async Task RunOutboundLoopAsync(CancellationToken stoppingToken) headers[MessageHeaders.TraceState] = traceState; } - await _bus.PublishAsync(_options.EffectiveTopic, new PubSubEntry { Body = body, Headers = headers }, stoppingToken).ConfigureAwait(false); + await _bus.PublishAsync(_options.EffectiveTopic, [new PubSubEntry { Body = body, Headers = headers }], stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { diff --git a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs index ceb67a49..f8e198dd 100644 --- a/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs +++ b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs @@ -101,7 +101,7 @@ public static IMediatorBuilder AddDistributedQueues( // Always register infrastructure (queues must exist for enqueuing from API-only nodes). // Dead-letter queues are created lazily on first dead-letter to reduce startup latency. - infraOptions.QueueNames.Add(queueName); + infraOptions.QueueNames.Add(new QueueDefinition { Name = queueName }); var visibilityTimeout = TimeSpan.FromSeconds(queueAttr?.TimeoutSeconds ?? 30); var retryDelay = TimeSpan.FromSeconds(queueAttr?.RetryDelaySeconds ?? 5); @@ -236,7 +236,7 @@ public static IMediatorBuilder AddDistributedNotifications( // Collect topic name for startup initialization var infraOptions = GetOrAddInfrastructureOptions(services); - infraOptions.TopicNames.Add(options.EffectiveTopic); + infraOptions.TopicNames.Add(new TopicDefinition { Name = options.EffectiveTopic }); // Register the background worker // Build the resolved set of distributed types for the worker to filter on. diff --git a/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj index 80589b85..24eca36e 100644 --- a/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj +++ b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Foundatio.Mediator.Distributed/IPubSubClient.cs b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs index 97357f96..0e4392d2 100644 --- a/src/Foundatio.Mediator.Distributed/IPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs @@ -18,12 +18,13 @@ namespace Foundatio.Mediator.Distributed; public interface IPubSubClient : IAsyncDisposable { /// - /// Publishes a message to all subscribers of the specified topic. + /// Publishes one or more messages to all subscribers of the specified topic. + /// Implementations may use transport-native batch APIs for better throughput. /// /// The topic to publish to. - /// The outbound message containing body and optional headers. + /// The outbound messages containing body and optional headers. /// A cancellation token. - Task PublishAsync(string topic, PubSubEntry message, CancellationToken cancellationToken = default); + Task PublishAsync(string topic, IReadOnlyList messages, CancellationToken cancellationToken = default); /// /// Subscribes to a topic. The returned unsubscribes when disposed. @@ -39,7 +40,7 @@ public interface IPubSubClient : IAsyncDisposable /// Implementations create topics, per-node queues, and subscriptions so that /// can skip to polling without additional API calls. /// - Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) => Task.CompletedTask; + Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) => Task.CompletedTask; /// ValueTask IAsyncDisposable.DisposeAsync() => default; diff --git a/src/Foundatio.Mediator.Distributed/IQueueClient.cs b/src/Foundatio.Mediator.Distributed/IQueueClient.cs index f682208a..3c99ea6d 100644 --- a/src/Foundatio.Mediator.Distributed/IQueueClient.cs +++ b/src/Foundatio.Mediator.Distributed/IQueueClient.cs @@ -7,14 +7,10 @@ namespace Foundatio.Mediator.Distributed; public interface IQueueClient : IAsyncDisposable { /// - /// Sends a single message to the specified queue. + /// Sends one or more messages to the specified queue. + /// Implementations may use transport-native batch APIs for better throughput. /// - Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default); - - /// - /// Sends a batch of messages to the specified queue. - /// - Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default); + Task SendAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default); /// /// Receives up to messages from the specified queue. @@ -67,14 +63,14 @@ public interface IQueueClient : IAsyncDisposable /// Ensures the specified queues exist, creating them if necessary. /// Implementations may batch the operations for efficiency. /// - Task EnsureQueuesAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) => Task.CompletedTask; + Task EnsureQueuesAsync(IReadOnlyList queues, CancellationToken cancellationToken = default) => Task.CompletedTask; /// - /// Gets transport-level statistics for the specified queue. + /// Gets transport-level statistics for the specified queues. /// Not all transports support all metrics; unsupported values will be zero. /// - Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) - => Task.FromResult(new QueueStats { QueueName = queueName }); + Task> GetQueueStatsAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + => Task.FromResult>(queueNames.Select(n => new QueueStats { QueueName = n }).ToList()); /// ValueTask IAsyncDisposable.DisposeAsync() => default; diff --git a/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs index 54fbf733..1f763df4 100644 --- a/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs @@ -14,21 +14,24 @@ public sealed class InMemoryPubSubClient : IPubSubClient, IDisposable private readonly ConcurrentBag _activeCts = []; /// - public Task PublishAsync(string topic, PubSubEntry entry, CancellationToken cancellationToken = default) + public Task PublishAsync(string topic, IReadOnlyList entries, CancellationToken cancellationToken = default) { - if (!_subscriptions.TryGetValue(topic, out var entries)) + if (!_subscriptions.TryGetValue(topic, out var subs)) return Task.CompletedTask; - var message = new PubSubMessage + foreach (var entry in entries) { - Body = entry.Body, - Headers = entry.Headers is not null - ? new Dictionary(entry.Headers) - : new Dictionary() - }; - - foreach (var sub in entries.Values) - sub.Writer.TryWrite(message); + var message = new PubSubMessage + { + Body = entry.Body, + Headers = entry.Headers is not null + ? new Dictionary(entry.Headers) + : new Dictionary() + }; + + foreach (var sub in subs.Values) + sub.Writer.TryWrite(message); + } return Task.CompletedTask; } diff --git a/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs b/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs index 7d678692..3cf33ce9 100644 --- a/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs @@ -20,25 +20,22 @@ public InMemoryQueueClient(TimeProvider? timeProvider = null) _timeProvider = timeProvider ?? TimeProvider.System; } - public Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default) + public async Task SendAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) { var channel = GetOrCreateChannel(queueName); - var internalEntry = new InMemoryEntry - { - Id = Guid.NewGuid().ToString("N"), - Body = entry.Body, - Headers = entry.Headers != null ? new Dictionary(entry.Headers) : new(), - DequeueCount = 0, - EnqueuedAt = _timeProvider.GetUtcNow() - }; - - return channel.Writer.WriteAsync(internalEntry, cancellationToken).AsTask(); - } - - public async Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) - { foreach (var entry in entries) - await SendAsync(queueName, entry, cancellationToken).ConfigureAwait(false); + { + var internalEntry = new InMemoryEntry + { + Id = Guid.NewGuid().ToString("N"), + Body = entry.Body, + Headers = entry.Headers != null ? new Dictionary(entry.Headers) : new(), + DequeueCount = 0, + EnqueuedAt = _timeProvider.GetUtcNow() + }; + + await channel.Writer.WriteAsync(internalEntry, cancellationToken).ConfigureAwait(false); + } } public async Task> ReceiveAsync(string queueName, int maxCount, CancellationToken cancellationToken = default) @@ -157,22 +154,28 @@ private Channel GetOrCreateDeadLetterChannel(string queueName) new UnboundedChannelOptions { SingleReader = false, SingleWriter = false })); /// - public Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) + public Task> GetQueueStatsAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) { - int activeCount = 0; - if (_channels.TryGetValue(queueName, out var channel)) - activeCount = channel.Reader.Count; + var results = new List(queueNames.Count); + foreach (var queueName in queueNames) + { + int activeCount = 0; + if (_channels.TryGetValue(queueName, out var channel)) + activeCount = channel.Reader.Count; - int deadLetterCount = 0; - if (_deadLetterChannels.TryGetValue(queueName, out var dlqChannel)) - deadLetterCount = dlqChannel.Reader.Count; + int deadLetterCount = 0; + if (_deadLetterChannels.TryGetValue(queueName, out var dlqChannel)) + deadLetterCount = dlqChannel.Reader.Count; - return Task.FromResult(new QueueStats - { - QueueName = queueName, - ActiveCount = activeCount, - DeadLetterCount = deadLetterCount - }); + results.Add(new QueueStats + { + QueueName = queueName, + ActiveCount = activeCount, + DeadLetterCount = deadLetterCount + }); + } + + return Task.FromResult>(results); } private static QueueMessage ToQueueMessage(InMemoryEntry entry, string queueName, DateTimeOffset dequeuedAt) => new() diff --git a/src/Foundatio.Mediator.Distributed/QueueClientBase.cs b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs index 7615f0f3..2eb7937e 100644 --- a/src/Foundatio.Mediator.Distributed/QueueClientBase.cs +++ b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs @@ -15,7 +15,7 @@ namespace Foundatio.Mediator.Distributed; /// , . /// Recommended: (if the transport supports visibility timeouts), /// (if infrastructure pre-creation is beneficial). -/// Optional: (default loops ), +/// Optional: /// (default sends to {queueName}-dead-letter then completes), /// (default returns zeroed stats). /// @@ -36,18 +36,7 @@ protected QueueClientBase(ILogger? logger = null) Logger = logger ?? NullLogger.Instance; } /// - public abstract Task SendAsync(string queueName, QueueEntry entry, CancellationToken cancellationToken = default); - - /// - /// - /// Default implementation sends entries one at a time via . - /// Override to use transport-native batch APIs for better throughput. - /// - public virtual async Task SendBatchAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) - { - foreach (var entry in entries) - await SendAsync(queueName, entry, cancellationToken).ConfigureAwait(false); - } + public abstract Task SendAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default); /// public abstract Task> ReceiveAsync(string queueName, int maxCount, CancellationToken cancellationToken = default); @@ -100,7 +89,7 @@ public virtual async Task DeadLetterAsync(QueueMessage message, string reason, C Headers = headers }; - await SendAsync(dlqName, entry, cancellationToken).ConfigureAwait(false); + await SendAsync(dlqName, [entry], cancellationToken).ConfigureAwait(false); await CompleteAsync(message, cancellationToken).ConfigureAwait(false); } @@ -108,7 +97,7 @@ public virtual async Task DeadLetterAsync(QueueMessage message, string reason, C /// /// Default implementation is a no-op. Override to pre-create queue infrastructure at startup. /// - public virtual Task EnsureQueuesAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + public virtual Task EnsureQueuesAsync(IReadOnlyList queues, CancellationToken cancellationToken = default) => Task.CompletedTask; /// @@ -116,8 +105,8 @@ public virtual Task EnsureQueuesAsync(IReadOnlyList queueNames, Cancella /// Default implementation returns zeroed stats. Override if the transport provides /// queue metrics (approximate message count, in-flight count, etc.). /// - public virtual Task GetQueueStatsAsync(string queueName, CancellationToken cancellationToken = default) - => Task.FromResult(new QueueStats { QueueName = queueName }); + public virtual Task> GetQueueStatsAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + => Task.FromResult>(queueNames.Select(n => new QueueStats { QueueName = n }).ToList()); /// public virtual ValueTask DisposeAsync() => default; diff --git a/src/Foundatio.Mediator.Distributed/QueueDefinition.cs b/src/Foundatio.Mediator.Distributed/QueueDefinition.cs new file mode 100644 index 00000000..1e61a5c8 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueDefinition.cs @@ -0,0 +1,15 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Describes a queue that should be created or ensured by the transport. +/// +public class QueueDefinition +{ + /// + /// The transport-level queue name. + /// + public required string Name { get; init; } + + /// + public override string ToString() => Name; +} diff --git a/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs index 0d7b8b8b..4d759bb5 100644 --- a/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs +++ b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs @@ -116,7 +116,7 @@ public QueueMiddleware(IQueueClient client, HandlerRegistry registry, Distribute Headers = headers }; - await _client.SendAsync(metadata.QueueName, entry, cancellationToken).ConfigureAwait(false); + await _client.SendAsync(metadata.QueueName, [entry], cancellationToken).ConfigureAwait(false); if (jobId is not null) return Result.Accepted("Message queued", jobId); diff --git a/src/Foundatio.Mediator.Distributed/TopicDefinition.cs b/src/Foundatio.Mediator.Distributed/TopicDefinition.cs new file mode 100644 index 00000000..de02af0e --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/TopicDefinition.cs @@ -0,0 +1,15 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Describes a topic that should be created or ensured by the transport. +/// +public class TopicDefinition +{ + /// + /// The transport-level topic name. + /// + public required string Name { get; init; } + + /// + public override string ToString() => Name; +} diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj b/tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj index becb0abf..022a026c 100644 --- a/tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/Foundatio.Mediator.Distributed.Aws.Tests.csproj @@ -1,6 +1,6 @@ - + net10.0 @@ -12,12 +12,12 @@ - - + + - + diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs index 850957f7..71d7cf38 100644 --- a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs @@ -53,7 +53,7 @@ public async Task PublishAsync_WithNoSubscribers_DoesNotThrow() { await using var client = CreateClient(); - await client.PublishAsync("no-sub-topic", new PubSubEntry { Body = "hello"u8.ToArray() }, TestCancellationToken); + await client.PublishAsync("no-sub-topic", [new PubSubEntry { Body = "hello"u8.ToArray() }], TestCancellationToken); } [Fact] @@ -73,7 +73,7 @@ public async Task SubscribeAsync_ReceivesPublishedMessage() }, TestCancellationToken); var headers = new Dictionary { ["key"] = "value" }; - await client.PublishAsync(topic, new PubSubEntry { Body = "hello"u8.ToArray(), Headers = headers }, TestCancellationToken); + await client.PublishAsync(topic, [new PubSubEntry { Body = "hello"u8.ToArray(), Headers = headers }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30)), "Timed out waiting for message"); @@ -100,7 +100,7 @@ public async Task SubscribeAsync_MultipleMessages_AllReceived() }, TestCancellationToken); for (int i = 0; i < 3; i++) - await client.PublishAsync(topic, new PubSubEntry { Body = System.Text.Encoding.UTF8.GetBytes($"msg-{i}") }, TestCancellationToken); + await client.PublishAsync(topic, [new PubSubEntry { Body = System.Text.Encoding.UTF8.GetBytes($"msg-{i}") }], TestCancellationToken); for (int i = 0; i < 3; i++) Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30)), $"Timed out waiting for message {i}"); @@ -132,7 +132,7 @@ public async Task SubscribeAsync_HeadersRoundTrip() ["h2"] = "v2", ["h3"] = "v3" }; - await client.PublishAsync(topic, new PubSubEntry { Body = "test"u8.ToArray(), Headers = headers }, TestCancellationToken); + await client.PublishAsync(topic, [new PubSubEntry { Body = "test"u8.ToArray(), Headers = headers }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); Assert.NotNull(received); @@ -157,14 +157,14 @@ public async Task DisposeSubscription_StopsReceiving() return Task.CompletedTask; }, TestCancellationToken); - await client.PublishAsync(topic, new PubSubEntry { Body = "msg1"u8.ToArray() }, TestCancellationToken); + await client.PublishAsync(topic, [new PubSubEntry { Body = "msg1"u8.ToArray() }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); Assert.Equal(1, count); // Dispose subscription await sub.DisposeAsync(); - await client.PublishAsync(topic, new PubSubEntry { Body = "msg2"u8.ToArray() }, TestCancellationToken); + await client.PublishAsync(topic, [new PubSubEntry { Body = "msg2"u8.ToArray() }], TestCancellationToken); await Task.Delay(TimeSpan.FromSeconds(3), TestCancellationToken); Assert.Equal(1, count); // Should not have received msg2 @@ -186,7 +186,7 @@ public async Task PublishAsync_NoHeaders_ReceivesEmptyHeaders() return Task.CompletedTask; }, TestCancellationToken); - await client.PublishAsync(topic, new PubSubEntry { Body = "no-headers"u8.ToArray() }, TestCancellationToken); + await client.PublishAsync(topic, [new PubSubEntry { Body = "no-headers"u8.ToArray() }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(30))); Assert.NotNull(received); diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs index 701849e6..f5b0ec97 100644 --- a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsQueueClientTests.cs @@ -43,11 +43,11 @@ public async Task SendAsync_LargeHeaders_RoundTrip() for (int i = 0; i < 10; i++) headers[$"header-{i}"] = $"value-{i}-{new string('x', 100)}"; - await client.SendAsync(queueName, new QueueEntry + await client.SendAsync(queueName, [new QueueEntry { Body = "test"u8.ToArray(), Headers = headers - }, TestCancellationToken); + }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); Assert.Single(messages); @@ -65,7 +65,7 @@ public async Task AbandonAsync_MakesMessageImmediatelyVisible() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "abandon-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "abandon-test"u8.ToArray() }], TestCancellationToken); var first = await client.ReceiveAsync(queueName, 1, TestCancellationToken); Assert.Single(first); @@ -85,7 +85,7 @@ public async Task CompleteAsync_DeletesMessage_FromSqs() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "delete-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "delete-test"u8.ToArray() }], TestCancellationToken); var msgs = await client.ReceiveAsync(queueName, 1, TestCancellationToken); Assert.Single(msgs); @@ -99,7 +99,7 @@ public async Task CompleteAsync_DeletesMessage_FromSqs() } [Fact] - public async Task SendBatchAsync_SendsUpToTenMessages() + public async Task SendAsync_Batch_SendsUpToTenMessages() { var client = CreateClient(); var queueName = TestQueueName; @@ -111,7 +111,7 @@ public async Task SendBatchAsync_SendsUpToTenMessages() Headers = new Dictionary { ["index"] = i.ToString() } }).ToList(); - await client.SendBatchAsync(queueName, entries, TestCancellationToken); + await client.SendAsync(queueName, entries, TestCancellationToken); var received = new List(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); @@ -125,7 +125,7 @@ public async Task SendBatchAsync_SendsUpToTenMessages() } [Fact] - public async Task SendBatchAsync_MoreThanTen_SplitsIntoBatches() + public async Task SendAsync_Batch_MoreThanTen_SplitsIntoBatches() { var client = CreateClient(); var queueName = TestQueueName; @@ -136,7 +136,7 @@ public async Task SendBatchAsync_MoreThanTen_SplitsIntoBatches() Body = System.Text.Encoding.UTF8.GetBytes($"big-batch-{i}") }).ToList(); - await client.SendBatchAsync(queueName, entries, TestCancellationToken); + await client.SendAsync(queueName, entries, TestCancellationToken); var received = new List(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); @@ -155,7 +155,7 @@ public async Task ReceivedMessage_HasSqsMetadata() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "metadata-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "metadata-test"u8.ToArray() }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); Assert.Single(messages); @@ -178,7 +178,7 @@ public async Task RenewTimeoutAsync_ExtendsVisibility() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "renew-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "renew-test"u8.ToArray() }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); Assert.Single(messages); @@ -199,11 +199,11 @@ public async Task DeadLetterAsync_SendsMessageToDLQAndCompletesOriginal() var queueName = TestQueueName; var dlqName = $"{queueName}-dead-letter"; - await client.SendAsync(queueName, new QueueEntry + await client.SendAsync(queueName, [new QueueEntry { Body = "poison"u8.ToArray(), Headers = new Dictionary { ["custom"] = "value" } - }, TestCancellationToken); + }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); Assert.Single(messages); @@ -235,7 +235,7 @@ public async Task AbandonAsync_WithDelay_MakesMessageVisibleAfterDelay() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "delay-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "delay-test"u8.ToArray() }], TestCancellationToken); var first = await client.ReceiveAsync(queueName, 1, TestCancellationToken); Assert.Single(first); diff --git a/tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj b/tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj index 176f70d2..1ef110e1 100644 --- a/tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj +++ b/tests/Foundatio.Mediator.Distributed.Redis.Tests/Foundatio.Mediator.Distributed.Redis.Tests.csproj @@ -1,6 +1,6 @@ - + net10.0 @@ -12,13 +12,13 @@ - - - + + + - + diff --git a/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs index 1713f195..903e13cf 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs @@ -786,10 +786,10 @@ public async Task PublishAsync_FilterMismatch_NotSentToBus() // ── Test helper: counting bus decorator ────────────────────────────── internal sealed class CountingPubSubClient(IPubSubClient inner, Action onPublish) : IPubSubClient { - public async Task PublishAsync(string topic, PubSubEntry message, CancellationToken cancellationToken = default) + public async Task PublishAsync(string topic, IReadOnlyList messages, CancellationToken cancellationToken = default) { onPublish(); - await inner.PublishAsync(topic, message, cancellationToken); + await inner.PublishAsync(topic, messages, cancellationToken); } public Task SubscribeAsync(string topic, Func handler, CancellationToken cancellationToken = default) diff --git a/tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj b/tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj index 3d73f9ce..1bcd7dc6 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj +++ b/tests/Foundatio.Mediator.Distributed.Tests/Foundatio.Mediator.Distributed.Tests.csproj @@ -25,12 +25,12 @@ - + - + diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs index c7a801af..53d9f31a 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs @@ -10,7 +10,7 @@ public async Task PublishAsync_WithNoSubscribers_DoesNotThrow() { using var bus = new InMemoryPubSubClient(); - await bus.PublishAsync("test-topic", new PubSubEntry { Body = "hello"u8.ToArray() }, TestCancellationToken); + await bus.PublishAsync("test-topic", [new PubSubEntry { Body = "hello"u8.ToArray() }], TestCancellationToken); } [Fact] @@ -29,7 +29,7 @@ public async Task SubscribeAsync_ReceivesPublishedMessage() }, TestCancellationToken); var headers = new Dictionary { ["key"] = "value" }; - await bus.PublishAsync("test-topic", new PubSubEntry { Body = "hello"u8.ToArray(), Headers = headers }, TestCancellationToken); + await bus.PublishAsync("test-topic", [new PubSubEntry { Body = "hello"u8.ToArray(), Headers = headers }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); Assert.NotNull(received); @@ -59,7 +59,7 @@ public async Task SubscribeAsync_MultipleSubscribers_AllReceive() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic", new PubSubEntry { Body = "data"u8.ToArray() }, TestCancellationToken); + await bus.PublishAsync("topic", [new PubSubEntry { Body = "data"u8.ToArray() }], TestCancellationToken); // Wait for both subscribers Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); @@ -90,7 +90,7 @@ public async Task SubscribeAsync_DifferentTopics_OnlyMatchingReceives() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic-a", new PubSubEntry { Body = "only-a"u8.ToArray() }, TestCancellationToken); + await bus.PublishAsync("topic-a", [new PubSubEntry { Body = "only-a"u8.ToArray() }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); // Give a moment to ensure topic-b doesn't fire @@ -115,14 +115,14 @@ public async Task DisposeSubscription_StopsReceiving() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic", new PubSubEntry { Body = "msg1"u8.ToArray() }, TestCancellationToken); + await bus.PublishAsync("topic", [new PubSubEntry { Body = "msg1"u8.ToArray() }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); Assert.Equal(1, count); // Dispose subscription await sub.DisposeAsync(); - await bus.PublishAsync("topic", new PubSubEntry { Body = "msg2"u8.ToArray() }, TestCancellationToken); + await bus.PublishAsync("topic", [new PubSubEntry { Body = "msg2"u8.ToArray() }], TestCancellationToken); await Task.Delay(200, TestCancellationToken); Assert.Equal(1, count); // Should not have received msg2 @@ -143,11 +143,11 @@ public async Task PublishAsync_HeadersAreReadOnly() return Task.CompletedTask; }, TestCancellationToken); - await bus.PublishAsync("topic", new PubSubEntry + await bus.PublishAsync("topic", [new PubSubEntry { Body = "test"u8.ToArray(), Headers = new Dictionary { ["h1"] = "v1", ["h2"] = "v2" } - }, TestCancellationToken); + }], TestCancellationToken); Assert.True(await signal.WaitAsync(TimeSpan.FromSeconds(5))); Assert.NotNull(received); diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs index 0520c543..ba8d0e5c 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueClientTests.cs @@ -14,8 +14,8 @@ public async Task MultipleQueues_AreIsolated() var q1 = $"queue-a-{Guid.NewGuid():N}"; var q2 = $"queue-b-{Guid.NewGuid():N}"; - await client.SendAsync(q1, new QueueEntry { Body = "a"u8.ToArray() }, TestCancellationToken); - await client.SendAsync(q2, new QueueEntry { Body = "b"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(q1, [new QueueEntry { Body = "a"u8.ToArray() }], TestCancellationToken); + await client.SendAsync(q2, [new QueueEntry { Body = "b"u8.ToArray() }], TestCancellationToken); var msgs1 = await client.ReceiveAsync(q1, 10, TestCancellationToken); var msgs2 = await client.ReceiveAsync(q2, 10, TestCancellationToken); @@ -32,7 +32,7 @@ public async Task AbandonAsync_IncrementsDequeueCount() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "retry-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "retry-test"u8.ToArray() }], TestCancellationToken); // Receive, abandon, receive again β€” dequeue count should increase var first = await client.ReceiveAsync(queueName, 1, TestCancellationToken); @@ -55,7 +55,7 @@ public async Task CompleteAsync_ThenReceive_ReturnsEmpty() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "complete-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "complete-test"u8.ToArray() }], TestCancellationToken); var msgs = await client.ReceiveAsync(queueName, 10, TestCancellationToken); await client.CompleteAsync(msgs[0], TestCancellationToken); @@ -65,7 +65,7 @@ public async Task CompleteAsync_ThenReceive_ReturnsEmpty() } [Fact] - public async Task SendBatchAsync_Ordering_PreservedApproximately() + public async Task SendAsync_Batch_Ordering_PreservedApproximately() { var client = CreateClient(); var queueName = TestQueueName; @@ -75,7 +75,7 @@ public async Task SendBatchAsync_Ordering_PreservedApproximately() Body = new byte[] { (byte)i } }).ToList(); - await client.SendBatchAsync(queueName, entries, TestCancellationToken); + await client.SendAsync(queueName, entries, TestCancellationToken); var received = new List(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); @@ -100,7 +100,7 @@ public async Task ConcurrentSendAndReceive_AllMessagesDelivered() // Send concurrently var sendTasks = Enumerable.Range(0, messageCount).Select(i => - client.SendAsync(queueName, new QueueEntry { Body = new byte[] { (byte)(i % 256) } }, TestCancellationToken)); + client.SendAsync(queueName, [new QueueEntry { Body = new byte[] { (byte)(i % 256) } }], TestCancellationToken)); await Task.WhenAll(sendTasks); // Receive all @@ -123,11 +123,11 @@ public async Task DeadLetterAsync_MovesMessageToDLQ() var client = (InMemoryQueueClient)CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry + await client.SendAsync(queueName, [new QueueEntry { Body = "poison"u8.ToArray(), Headers = new Dictionary { ["custom"] = "value" } - }, TestCancellationToken); + }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); Assert.Single(messages); @@ -151,7 +151,7 @@ public async Task DeadLetterAsync_PreservesOriginalHeaders() var client = (InMemoryQueueClient)CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry + await client.SendAsync(queueName, [new QueueEntry { Body = "test"u8.ToArray(), Headers = new Dictionary @@ -159,7 +159,7 @@ public async Task DeadLetterAsync_PreservesOriginalHeaders() [MessageHeaders.MessageType] = "MyMessage", ["custom-key"] = "custom-value" } - }, TestCancellationToken); + }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 1, TestCancellationToken); await client.DeadLetterAsync(messages[0], "Test reason", TestCancellationToken); @@ -184,7 +184,7 @@ public async Task DeadLetterAsync_PreservesDequeueCount() var client = (InMemoryQueueClient)CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "dlq-count"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "dlq-count"u8.ToArray() }], TestCancellationToken); // Receive and abandon twice to bump dequeue count var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; @@ -212,7 +212,7 @@ public async Task GetDeadLetterCount_ReturnsCorrectCount() // Dead-letter three messages for (int i = 0; i < 3; i++) { - await client.SendAsync(queueName, new QueueEntry { Body = new byte[] { (byte)i } }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = new byte[] { (byte)i } }], TestCancellationToken); var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; await client.DeadLetterAsync(msg, $"reason-{i}", TestCancellationToken); } @@ -229,7 +229,7 @@ public async Task AbandonAsync_WithDelay_MessageNotVisibleUntilTimeAdvances() var client = new InMemoryQueueClient(fakeTime); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "delayed"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "delayed"u8.ToArray() }], TestCancellationToken); var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; // Start abandon with 30s delay β€” it will block on Task.Delay @@ -258,7 +258,7 @@ public async Task AbandonAsync_ZeroDelay_ImmediatelyRequeues() var client = new InMemoryQueueClient(fakeTime); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "instant"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "instant"u8.ToArray() }], TestCancellationToken); var msg = (await client.ReceiveAsync(queueName, 1, TestCancellationToken))[0]; await client.AbandonAsync(msg, cancellationToken: TestCancellationToken); @@ -276,7 +276,7 @@ public async Task Timestamps_UseFakeTimeProvider() var client = new InMemoryQueueClient(fakeTime); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "time-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "time-test"u8.ToArray() }], TestCancellationToken); // Advance 5 minutes before receiving fakeTime.Advance(TimeSpan.FromMinutes(5)); diff --git a/tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs b/tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs index 7b60b59a..c6580fa2 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/QueueClientTestBase.cs @@ -28,11 +28,11 @@ public async Task SendAsync_ThenReceiveAsync_ReturnsMessage() [MessageHeaders.EnqueuedAt] = DateTimeOffset.UtcNow.ToString("O") }; - await client.SendAsync(queueName, new QueueEntry + await client.SendAsync(queueName, [new QueueEntry { Body = body, Headers = headers - }, TestCancellationToken); + }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); @@ -52,7 +52,7 @@ public async Task SendAsync_WithNoHeaders_RoundTripsBody() var queueName = TestQueueName; var body = """{"Value":42}"""u8.ToArray(); - await client.SendAsync(queueName, new QueueEntry { Body = body }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = body }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); @@ -82,10 +82,10 @@ public async Task ReceiveAsync_RespectsMaxCount() // Send 5 messages for (int i = 0; i < 5; i++) { - await client.SendAsync(queueName, new QueueEntry + await client.SendAsync(queueName, [new QueueEntry { Body = Encoding.UTF8.GetBytes($"message-{i}") - }, TestCancellationToken); + }], TestCancellationToken); } // Request only 2 @@ -102,7 +102,7 @@ public async Task CompleteAsync_RemovesMessage() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "hello"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "hello"u8.ToArray() }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); Assert.Single(messages); @@ -123,7 +123,7 @@ public async Task AbandonAsync_RequeuesMessage() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "requeue-me"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "requeue-me"u8.ToArray() }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); Assert.Single(messages); @@ -147,7 +147,7 @@ public async Task RenewTimeoutAsync_DoesNotThrow() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "timeout-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "timeout-test"u8.ToArray() }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); Assert.Single(messages); @@ -159,10 +159,10 @@ public async Task RenewTimeoutAsync_DoesNotThrow() await client.CompleteAsync(messages[0], TestCancellationToken); } - // ── SendBatch ────────────────────────────────────────────────────────── + // ── Send Batch ───────────────────────────────────────────────────────── [Fact] - public async Task SendBatchAsync_SendsAllMessages() + public async Task SendAsync_Batch_SendsAllMessages() { var client = CreateClient(); var queueName = TestQueueName; @@ -173,7 +173,7 @@ public async Task SendBatchAsync_SendsAllMessages() Headers = new Dictionary { ["index"] = i.ToString() } }).ToList(); - await client.SendBatchAsync(queueName, entries, TestCancellationToken); + await client.SendAsync(queueName, entries, TestCancellationToken); // Receive all β€” may need multiple receives for SQS var received = new List(); @@ -203,11 +203,11 @@ public async Task Headers_RoundTrip() ["custom-header"] = "custom-value" }; - await client.SendAsync(queueName, new QueueEntry + await client.SendAsync(queueName, [new QueueEntry { Body = "{}"u8.ToArray(), Headers = headers - }, TestCancellationToken); + }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); Assert.Single(messages); @@ -227,7 +227,7 @@ public async Task ReceivedMessage_HasMetadata() var client = CreateClient(); var queueName = TestQueueName; - await client.SendAsync(queueName, new QueueEntry { Body = "meta-test"u8.ToArray() }, TestCancellationToken); + await client.SendAsync(queueName, [new QueueEntry { Body = "meta-test"u8.ToArray() }], TestCancellationToken); var messages = await client.ReceiveAsync(queueName, 10, TestCancellationToken); Assert.Single(messages); diff --git a/tests/Foundatio.Mediator.Tests/Foundatio.Mediator.Tests.csproj b/tests/Foundatio.Mediator.Tests/Foundatio.Mediator.Tests.csproj index d6cf6c86..5cebbbbf 100644 --- a/tests/Foundatio.Mediator.Tests/Foundatio.Mediator.Tests.csproj +++ b/tests/Foundatio.Mediator.Tests/Foundatio.Mediator.Tests.csproj @@ -25,7 +25,7 @@ - + From fa998b491ebb1a33e80b6cfae67cc5468e243a31 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Apr 2026 17:58:00 -0500 Subject: [PATCH 27/27] Address PR review feedback from code quality analysis - Fix potential null dereference in InMemoryQueueJobStateStore.GetJobStateAsync by separating TryGetValue and IsExpired checks into distinct branches - Fix double-checked locking false positive in SqsPubSubClient.EnsureSharedQueueAsync by reading _sharedQueue into a local variable before each null check - Add using declarations to all SemaphoreSlim locals in SqsPubSubClientTests and InMemoryPubSubClientTests for proper deterministic disposal --- .../SqsPubSubClient.cs | 10 ++++++---- .../InMemoryQueueJobStateStore.cs | 12 ++++++------ .../SqsPubSubClientTests.cs | 10 +++++----- .../InMemoryPubSubClientTests.cs | 10 +++++----- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs index 8eb2c4e9..f39f4a68 100644 --- a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs @@ -93,14 +93,16 @@ public async Task SubscribeAsync(string topic, Func private async Task<(string QueueName, string QueueUrl, string QueueArn)> EnsureSharedQueueAsync(CancellationToken cancellationToken) { - if (_sharedQueue is not null) - return _sharedQueue.Value; + var current = _sharedQueue; + if (current is not null) + return current.Value; await _queueSetupLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (_sharedQueue is not null) - return _sharedQueue.Value; + current = _sharedQueue; + if (current is not null) + return current.Value; var queuePrefix = string.IsNullOrEmpty(_resourcePrefix) ? _options.QueuePrefix diff --git a/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs index 7dc03ca5..d37f4e5c 100644 --- a/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs @@ -34,15 +34,15 @@ public Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, Cance public Task GetJobStateAsync(string jobId, CancellationToken cancellationToken = default) { - if (_jobs.TryGetValue(jobId, out var entry) && !IsExpired(entry)) + if (!_jobs.TryGetValue(jobId, out var entry)) + return Task.FromResult(null); + + if (!IsExpired(entry)) return Task.FromResult(entry.State); // Remove expired entry on access - if (entry is not null) - { - _jobs.TryRemove(jobId, out _); - _cancellations.TryRemove(jobId, out _); - } + _jobs.TryRemove(jobId, out _); + _cancellations.TryRemove(jobId, out _); return Task.FromResult(null); } diff --git a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs index 71d7cf38..45617fb7 100644 --- a/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs @@ -63,7 +63,7 @@ public async Task SubscribeAsync_ReceivesPublishedMessage() var topic = $"test-{Guid.NewGuid():N}"; PubSubMessage? received = null; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var sub = await client.SubscribeAsync(topic, (msg, ct) => { @@ -89,7 +89,7 @@ public async Task SubscribeAsync_MultipleMessages_AllReceived() var topic = $"test-{Guid.NewGuid():N}"; var received = new List(); - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var sub = await client.SubscribeAsync(topic, (msg, ct) => { @@ -117,7 +117,7 @@ public async Task SubscribeAsync_HeadersRoundTrip() var topic = $"test-{Guid.NewGuid():N}"; PubSubMessage? received = null; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var sub = await client.SubscribeAsync(topic, (msg, ct) => { @@ -148,7 +148,7 @@ public async Task DisposeSubscription_StopsReceiving() var topic = $"test-{Guid.NewGuid():N}"; int count = 0; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); var sub = await client.SubscribeAsync(topic, (msg, ct) => { @@ -177,7 +177,7 @@ public async Task PublishAsync_NoHeaders_ReceivesEmptyHeaders() var topic = $"test-{Guid.NewGuid():N}"; PubSubMessage? received = null; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var sub = await client.SubscribeAsync(topic, (msg, ct) => { diff --git a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs index 53d9f31a..33e01e3e 100644 --- a/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs @@ -19,7 +19,7 @@ public async Task SubscribeAsync_ReceivesPublishedMessage() using var bus = new InMemoryPubSubClient(); PubSubMessage? received = null; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var sub = await bus.SubscribeAsync("test-topic", (msg, ct) => { @@ -43,7 +43,7 @@ public async Task SubscribeAsync_MultipleSubscribers_AllReceive() using var bus = new InMemoryPubSubClient(); int count1 = 0, count2 = 0; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var sub1 = await bus.SubscribeAsync("topic", (msg, ct) => { @@ -74,7 +74,7 @@ public async Task SubscribeAsync_DifferentTopics_OnlyMatchingReceives() using var bus = new InMemoryPubSubClient(); int topicACount = 0, topicBCount = 0; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var subA = await bus.SubscribeAsync("topic-a", (msg, ct) => { @@ -106,7 +106,7 @@ public async Task DisposeSubscription_StopsReceiving() using var bus = new InMemoryPubSubClient(); int count = 0; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); var sub = await bus.SubscribeAsync("topic", (msg, ct) => { @@ -134,7 +134,7 @@ public async Task PublishAsync_HeadersAreReadOnly() using var bus = new InMemoryPubSubClient(); PubSubMessage? received = null; - var signal = new SemaphoreSlim(0); + using var signal = new SemaphoreSlim(0); await using var sub = await bus.SubscribeAsync("topic", (msg, ct) => {
Queue StatusProcessedFailedThroughput Queued In Flight Dead LetterConcurrencyTracking
-
{worker.queueName}
-
{worker.messageType}
+
+
+ {worker.queueName} + Γ—{worker.concurrency}{#if worker.trackProgress} Β· tracked{/if} +
+ {#if worker.isRunning} @@ -214,19 +225,15 @@ {/if} {worker.messagesProcessed.toLocaleString()}{worker.messagesFailed.toLocaleString()}{worker.activeCount.toLocaleString()}{worker.inFlightCount.toLocaleString()}{worker.deadLetterCount.toLocaleString()}{worker.concurrency} - {#if worker.trackProgress} - - {:else} - β€” - {/if} + +
+ + +
{worker.activeCount.toLocaleString()}{worker.inFlightCount.toLocaleString()}{worker.deadLetterCount.toLocaleString()}
QueueStatus Throughput Queued In Flight -
- {worker.queueName} - Γ—{worker.concurrency}{#if worker.trackProgress} Β· tracked{/if} -
-
- {#if worker.isRunning} - - - - - - Running - - {:else} - - - Stopped - - {/if} + {worker.queueName}
+
{worker.activeCount.toLocaleString()}