diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b872566..29fd9f9a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,20 +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": "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/Foundatio.Mediator.slnx b/Foundatio.Mediator.slnx index 55212642..73deac6d 100644 --- a/Foundatio.Mediator.slnx +++ b/Foundatio.Mediator.slnx @@ -12,6 +12,8 @@ + + @@ -21,8 +23,15 @@ + + + + + + + diff --git a/README.md b/README.md index 160d295f..f7d3148c 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,32 @@ GET /api/todos/{id} → 200 OK / 404 Not Found Routes, HTTP methods, parameter binding, and OpenAPI metadata are all inferred from your message names and `Result` factory calls. +### Go distributed + +Ready to scale out? Add `[Queue]` to any handler — same code, now processed asynchronously via a background queue: + +```csharp +public record SendEmail(string To, string Subject, string Body); + +[Queue] +public class SendEmailHandler(IEmailService email) +{ + public async Task HandleAsync(SendEmail msg, CancellationToken ct) + => await email.SendAsync(msg.To, msg.Subject, msg.Body, ct); +} +``` + +The message is queued and processed asynchronously by a background worker. Full retry, dead-lettering, and progress tracking built in. + +Broadcast events across all nodes in your cluster with a marker interface: + +```csharp +public record ProductPriceChanged(string ProductId, decimal NewPrice) : IDistributedNotification; +``` + +Your handlers, middleware, DI, and error handling all work exactly the same — the distributed layer just changes _where_ execution happens. Pluggable transports for AWS SQS/SNS, with more coming soon. + + **👉 [Getting Started Guide](https://mediator.foundatio.dev/guide/getting-started.html)** — step-by-step setup with code samples for ASP.NET Core and console apps. **📖 [Complete Documentation](https://mediator.foundatio.dev)** 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/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/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/.vitepress/config.ts b/docs/.vitepress/config.ts index 810f3777..43c01e12 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -53,6 +53,15 @@ export default withMermaid(defineConfig({ { text: 'Testing', link: '/guide/testing' } ] }, + { + text: 'Distributed', + items: [ + { text: 'Going Distributed', link: '/guide/distributed-overview' }, + { text: 'Distributed Queues', link: '/guide/distributed-queues' }, + { text: 'Distributed Notifications', link: '/guide/distributed-notifications' }, + { text: 'Transport Providers', link: '/guide/distributed-transports' } + ] + }, { text: 'Advanced Topics', items: [ 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/docs/guide/distributed-notifications.md b/docs/guide/distributed-notifications.md new file mode 100644 index 00000000..63f1fd29 --- /dev/null +++ b/docs/guide/distributed-notifications.md @@ -0,0 +1,361 @@ +# Distributed Notifications + +Distributed notifications broadcast events across all nodes in your cluster. When one node publishes an event, every node hears about it — without changing your publishing code or handler structure. + +This is the pattern for cache invalidation, real-time state sync, configuration propagation, or any scenario where every instance of your app needs to react to the same event. + +## Installation + +```bash +dotnet add package Foundatio.Mediator.Distributed +``` + +Register the distributed notification services: + +```csharp +builder.Services.AddMediator() + .AddDistributedNotifications(); +``` + +By default, this uses an in-memory pub/sub — useful for single-process development. For multi-node deployments, add a [transport provider](./distributed-transports). + +## Making a Notification Distributed + +There are several ways to mark a notification for distributed fan-out. Pick whichever fits your architecture. + +### Option 1: Marker Interface + +Implement `IDistributedNotification` on your event record: + +```csharp +public record OrderCreated(string OrderId, string CustomerId, decimal Amount) + : IDistributedNotification; +``` + +`IDistributedNotification` extends `INotification` — it's a marker interface that tells the distributed infrastructure to broadcast this event beyond the local process. + +### Option 2: Attribute + +When you can't or don't want to modify the type hierarchy, use the `[DistributedNotification]` attribute: + +```csharp +[DistributedNotification] +public record OrderCreated(string OrderId, string CustomerId, decimal Amount); +``` + +This is equivalent to implementing `IDistributedNotification` — no interface needed. + +### Option 3: Options-Based Configuration + +For maximum flexibility, configure distribution at registration time without modifying message types at all: + +```csharp +builder.Services.AddMediator() + .AddDistributedNotifications(opts => + { + // Explicitly include specific types + opts.Include(); + opts.Include(); + + // Or include all notification types from an assembly + opts.IncludeNotificationsFromAssemblyOf(); + + // Or use a predicate for dynamic filtering + opts.MessageFilter = type => type.Namespace?.StartsWith("MyApp.Events") == true; + + // Or distribute everything + opts.IncludeAllNotifications = true; + }); +``` + +See [Controlling Which Types Are Distributed](#controlling-which-types-are-distributed) below for the full reference. + +Regardless of which approach you use, your publish code stays exactly the same: + +```csharp +await mediator.PublishAsync(new OrderCreated("ORD-001", "CUST-42", 99.99m)); +``` + +## How It Works + +When you publish a distributed notification: + +1. **Local handlers run first** — same as any notification, all matching handlers on the publishing node execute +2. **Outbound bridge** — the `DistributedNotificationWorker` picks up the event, serializes it, and publishes it to the configured pub/sub transport +3. **Remote nodes** — each node's worker receives the message, deserializes it, and publishes it locally via `mediator.PublishAsync()` +4. **Self-loop prevention** — the originating node ignores its own broadcast, so handlers don't fire twice + +```text +Node A Pub/Sub Bus Node B + │ │ │ + ├─ PublishAsync(event) ─────► │ │ + │ │ │ + │ ┌─ Local Handlers ─┐ │ │ + │ │ EmailHandler │ │ │ + │ │ AuditHandler │ │ │ + │ └──────────────────┘ │ │ + │ │ │ + ├─ serialize & publish ────────►│ │ + │ │ │ + │ ├─── message ───────────────►│ + │ │ │ + │ │ ┌─ Local Handlers ─┐ │ + │ │ │ CacheHandler │ │ + │ │ │ DashboardHandler │ │ + │ │ └──────────────────┘ │ +``` + +Each node runs its own local handlers for the event. The distributed layer just handles the transport. + +## Configuration + +```csharp +builder.Services.AddMediator() + .AddDistributedNotifications(opts => + { + opts.Topic = "app-events"; // Topic name (default: "distributed-notifications") + opts.HostId = "node-1"; // Unique ID per node (default: random GUID) + opts.ResourcePrefix = "myapp"; // Namespace prefix + opts.MaxCapacity = 1000; // Outbound buffer size (default: 1000) + }); +``` + +### Host Identity + +Each node needs a unique `HostId` to prevent self-loop broadcasting. By default, a random GUID is generated — this works for most deployments. Set it explicitly when you need stable identity for debugging or monitoring: + +```csharp +opts.HostId = Environment.MachineName; +// or +opts.HostId = Environment.GetEnvironmentVariable("HOSTNAME") ?? Guid.NewGuid().ToString("N"); +``` + +### Resource Prefixing + +Use `ResourcePrefix` to namespace your topics, avoiding collisions in shared infrastructure: + +```csharp +opts.ResourcePrefix = "myapp-prod"; +// Topic becomes: "myapp-prod-distributed-notifications" +``` + +## Controlling Which Types Are Distributed {#controlling-which-types-are-distributed} + +You have several mechanisms to control which notification types get distributed. They are evaluated in priority order — the first match wins: + +| Priority | Mechanism | Scope | +| -------- | --------- | ----- | +| 1 | `opts.Include()` | Per-type, at registration | +| 2 | `IDistributedNotification` interface | Per-type, in source | +| 3 | `[DistributedNotification]` attribute | Per-type, in source | +| 4 | `opts.MessageFilter` predicate | Dynamic, at registration | +| 5 | `opts.IncludeAllNotifications` flag | Global, at registration | + +### Explicit Include + +Registers specific types for distribution. Use this when the message types are defined in a library you don't control: + +```csharp +opts.Include(); +opts.Include(); +``` + +### Assembly Scanning + +Includes all types implementing `INotification` in the given assembly: + +```csharp +opts.IncludeNotificationsFromAssemblyOf(); +``` + +### Custom Predicate + +Filter by any criteria — namespace, naming convention, custom attributes, etc.: + +```csharp +// By namespace +opts.MessageFilter = type => type.Namespace?.StartsWith("MyApp.DomainEvents") == true; + +// By naming convention +opts.MessageFilter = type => type.Name.EndsWith("DomainEvent"); +``` + +The predicate is only evaluated for types that weren't already matched by `Include()`, `IDistributedNotification`, or `[DistributedNotification]`. + +### Include All Notifications + +Opt in to distribute every notification type. Useful for small applications or during development: + +```csharp +opts.IncludeAllNotifications = true; +``` + +::: warning +This distributes _all_ notification types, including those that may have been intentionally local-only. Use with care in production — every published notification will be serialized and sent to the bus. +::: + +### Combining Approaches + +All mechanisms work together. You can use the interface for most events and `Include()` for third-party types: + +```csharp +// OrderCreated uses the interface +public record OrderCreated(string OrderId) : IDistributedNotification; + +// ThirdPartyEvent uses explicit include +builder.Services.AddMediator() + .AddDistributedNotifications(opts => + { + opts.Include(); + }); +``` + +### Checking the Configuration + +You can verify whether a type would be distributed using `ShouldDistribute()`: + +```csharp +var options = new DistributedNotificationOptions(); +options.Include(); + +options.ShouldDistribute(typeof(OrderCreated)); // true +options.ShouldDistribute(typeof(LocalEvent)); // false +``` + +## Working with Handlers + +Distributed notifications use the same handler conventions as local notifications. Nothing special is required: + +```csharp +public class CacheInvalidationHandler +{ + public void Handle(ProductPriceChanged e, ICache cache) + { + cache.Remove($"product:{e.ProductId}"); + } +} + +public class DashboardUpdateHandler +{ + public async Task HandleAsync(ProductPriceChanged e, IDashboardService dashboard, CancellationToken ct) + { + await dashboard.RefreshProductAsync(e.ProductId, ct); + } +} +``` + +These handlers run on every node that receives the notification — including the originating node (where they run as normal local handlers before the event is broadcast). + +## Mixing Local and Distributed Events + +Not every event needs to be distributed. Only mark events for distribution when they need cross-node fanout. Regular events stay local and avoid the serialization overhead: + +```csharp +// Local only — no serialization, just in-process handlers +public record OrderValidated(string OrderId); + +// Distributed via interface +public record OrderCreated(string OrderId, string CustomerId) : IDistributedNotification; + +// Distributed via attribute +[DistributedNotification] +public record ProductUpdated(string ProductId, decimal NewPrice); +``` + +Both types work with `mediator.PublishAsync()`. The distributed layer only intercepts types that match the configured distribution criteria — whether via interface, attribute, or options. + +## With Queue Handlers + +Distributed notifications and queues work together naturally. A common pattern is to publish a distributed event that triggers queued work: + +```csharp +// Distributed event — all nodes hear about it +public record OrderCreated(string OrderId, string CustomerId, decimal Amount) + : IDistributedNotification; + +// Queue handler — processes audit logging asynchronously +[Queue(Group = "events")] +public class AuditEventHandler +{ + public async Task HandleAsync(OrderCreated e, IAuditService audit, CancellationToken ct) + { + await audit.LogAsync($"Order {e.OrderId} created", ct); + } +} + +// Local handler — runs on every node that receives the event +public class CacheHandler +{ + public void Handle(OrderCreated e, ICache cache) + { + cache.Remove("recent-orders"); + } +} +``` + +The same event triggers both a queued background job and a local cache invalidation on every node. + +## Middleware Integration + +Middleware can detect whether a message arrived via the distributed notification bus: + +```csharp +[Middleware] +public class ObservabilityMiddleware +{ + public void Before(object message, HandlerExecutionInfo info, ILogger logger) + { + var source = message is IDistributedNotification ? "distributed" : "local"; + logger.LogInformation("Handling {Type} (source: {Source})", + message.GetType().Name, source); + } +} +``` + +## Full Example + +```csharp +// Events +public record ProductPriceChanged(string ProductId, decimal OldPrice, decimal NewPrice) + : IDistributedNotification; + +public record ProductStockChanged(string ProductId, int OldQuantity, int NewQuantity) + : IDistributedNotification; + +// Handlers — run on every node +public class ProductCacheHandler +{ + public void Handle(ProductPriceChanged e, ICache cache) + => cache.Remove($"product:{e.ProductId}"); + + public void Handle(ProductStockChanged e, ICache cache) + => cache.Remove($"product:{e.ProductId}"); +} + +public class RealTimeNotificationHandler +{ + public async Task HandleAsync(ProductPriceChanged e, IHubContext hub, CancellationToken ct) + { + await hub.Clients.All.SendAsync("PriceChanged", new + { + e.ProductId, + e.NewPrice + }, ct); + } +} +``` + +```csharp +// DI registration +builder.Services.AddMediator() + .AddDistributedNotifications(opts => + { + opts.Topic = "product-events"; + }); +``` + +```csharp +// Publishing — same as always +await mediator.PublishAsync(new ProductPriceChanged("PROD-1", 29.99m, 24.99m)); +// Runs local handlers, then broadcasts to all other nodes +``` diff --git a/docs/guide/distributed-overview.md b/docs/guide/distributed-overview.md new file mode 100644 index 00000000..0d4c63d2 --- /dev/null +++ b/docs/guide/distributed-overview.md @@ -0,0 +1,100 @@ +# Going Distributed + +You've built your app with Foundatio Mediator. Messages flow through handlers, events trigger side effects, middleware handles cross-cutting concerns. Everything is loosely coupled, easy to test, and a joy to work with. Then the question comes: + +**"How do we scale this out?"** + +Traditionally, the answer is: rip out your in-process messaging and replace it with a completely different system — RabbitMQ, Kafka, SQS, whatever. New SDKs, new serialization, new error handling, new retry logic, new monitoring. Your beautiful mediator-based architecture gets buried under infrastructure plumbing. + +Foundatio Mediator takes a different approach: **the same messaging system you already love, now distributed.** + +## The Idea + +You've already invested in a loosely coupled, message-driven architecture. Your handlers don't know who calls them. Your events don't know who listens. That's _exactly_ the abstraction boundary you need -- the handlers don't care if the message came from the same process or from a queue on the other side of the world. + +- **Need to offload work to background workers?** Add `[Queue]` to the handler. Done. +- **Need all nodes in your cluster to hear about an event?** Implement `IDistributedNotification`, add `[DistributedNotification]`, or configure it in options. Done. + +Your handler code doesn't change. Your tests don't change. Your middleware still runs. You're just telling the infrastructure _where_ to execute, not _how_. + +## Two Patterns, One System + +Foundatio Mediator Distributed provides two complementary patterns: + +### Queues — Offload and Scale Out + +Queues are for **work that needs to happen exactly once**, processed by one consumer. Think background jobs, order processing, report generation, data imports. + +```csharp +[Queue] +public class OrderProcessingHandler +{ + public async Task HandleAsync(ProcessOrder cmd, IOrderService orders, CancellationToken ct) + { + await orders.ProcessAsync(cmd, ct); + return Result.Ok(); + } +} +``` + +When you call `mediator.InvokeAsync(new ProcessOrder(...))`, the message is serialized, sent to a queue, and returns immediately with `Result.Accepted()`. A worker picks it up and runs your handler — with full middleware, DI, and error handling. + +### Distributed Notifications — Broadcast Across Nodes + +Distributed notifications are for **events that every node needs to hear about**. Think cache invalidation, real-time updates, configuration changes. + +```csharp +// Option 1: Marker interface +public record ProductPriceChanged(string ProductId, decimal NewPrice) : IDistributedNotification; + +// Option 2: Attribute (no interface needed) +[DistributedNotification] +public record OrderShipped(string OrderId, DateTime ShippedAt); + +// Option 3: Configure in options (no changes to the type at all) +opts.Include(); +``` + +When you call `mediator.PublishAsync(new ProductPriceChanged(...))`: +1. Local handlers on the publishing node run immediately (as usual) +2. The event is automatically broadcast to all other nodes in the cluster +3. Remote nodes receive the event and run their own local handlers + +No code changes. No extra publish calls. Just mark which events to distribute. + +## What Doesn't Change + +This is important: **going distributed doesn't change your programming model.** Everything you already know about Foundatio Mediator still applies: + +| Feature | Still works? | +|---------|:---:| +| Convention-based handler discovery | ✅ | +| Middleware pipeline (Before/After/Finally) | ✅ | +| Dependency injection in handlers | ✅ | +| Result types and error handling | ✅ | +| Cascading messages | ✅ | +| Source generator optimizations | ✅ | +| OpenTelemetry tracing | ✅ | +| Authorization | ✅ | + +The distributed layer wraps your existing handlers — it doesn't replace them. + +## When to Use What + +| Scenario | Pattern | Why | +|----------|---------|-----| +| Background job processing | `[Queue]` | Offload long-running work | +| Scaling out CPU-intensive work | `[Queue]` with `Concurrency` | Multiple workers across nodes | +| At-least-once delivery guarantee | `[Queue]` | Built-in retry and dead-lettering | +| Cache invalidation across nodes | Distributed notification | All nodes need to react | +| Real-time event broadcast | Distributed notification | Fan-out to entire cluster | +| Progress tracking for long jobs | `[Queue(TrackProgress = true)]` | Job status and progress reporting | +| You don't need distribution at all | Neither | Just use Foundatio Mediator as-is | + +## Getting Started + +Ready to go distributed? Start with the specific guide for your scenario: + +- **[Distributed Queues](./distributed-queues)** — Background processing, retries, dead-lettering, progress tracking +- **[Distributed Notifications](./distributed-notifications)** — Cross-node event broadcasting +- **[Transport Providers](./distributed-transports)** — AWS SQS/SNS, Redis, and custom providers diff --git a/docs/guide/distributed-queues.md b/docs/guide/distributed-queues.md new file mode 100644 index 00000000..b39f7466 --- /dev/null +++ b/docs/guide/distributed-queues.md @@ -0,0 +1,428 @@ +# Distributed Queues + +Distributed queues let you offload handler execution to background workers — across processes, containers, or machines. Messages are serialized, sent to a queue, and processed asynchronously with full retry, dead-lettering, and optional progress tracking. + +The best part: your handler code barely changes. + +## Installation + +```bash +dotnet add package Foundatio.Mediator.Distributed +``` + +Register the distributed queue services: + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues(); +``` + +That's it. By default, this uses an in-memory queue — perfect for development and testing. For production, add a [transport provider](./distributed-transports). + +## Making a Handler Queue-Based + +Add `[Queue]` to any handler class: + +```csharp +[Queue] +public class OrderProcessingHandler +{ + public async Task HandleAsync( + ProcessOrder cmd, + IOrderService orders, + CancellationToken ct) + { + await orders.ProcessAsync(cmd, ct); + return Result.Ok(); + } +} +``` + +Now when you call `mediator.InvokeAsync(new ProcessOrder(...))`, instead of running the handler inline, the message is: + +1. Serialized to JSON +2. Sent to a queue named after the message type (e.g., `ProcessOrder`) +3. Returns immediately with `Result.Accepted()` + +A background worker picks up the message and runs your handler — with the full middleware pipeline, DI, and error handling intact. + +## How It Works + +The `[Queue]` attribute injects `QueueMiddleware` into the handler's middleware pipeline. This middleware intercepts the call: + +- **On the caller side:** Serializes the message, sends it to the queue, returns `Result.Accepted()` +- **On the worker side:** Deserializes the message, invokes the handler through the normal pipeline + +```text +Caller Queue Worker + │ │ │ + ├─ InvokeAsync(msg) ──────────►│ │ + │ │ │ + ◄── Result.Accepted() ─────────┤ │ + │ │ + ├─── message ─────────────────►│ + │ │ + │ ┌─ Middleware Pipeline ─┤ + │ │ Before hooks │ + │ │ Handler.HandleAsync │ + │ │ After hooks │ + │ └──────────────────────┘ + │ │ + ◄── complete / abandon ────────┤ +``` + +## Queue Configuration + +The `[Queue]` attribute accepts several configuration options: + +```csharp +[Queue( + QueueName = "custom-name", // Default: message type name + Concurrency = 5, // Concurrent consumers (default: 1) + PrefetchCount = 10, // Messages to prefetch (default: matches Concurrency) + MaxAttempts = 3, // Total attempts: 1 initial + 2 retries (default: 3) + TimeoutSeconds = 30, // Visibility timeout (default: 30) + RetryPolicy = QueueRetryPolicy.Exponential, // Retry strategy (default: Exponential) + RetryDelaySeconds = 5, // Base delay between retries (default: 5) + AutoComplete = true, // Auto-complete on success (default: true) + AutoRenewTimeout = true, // Auto-extend visibility timeout (default: true) + Group = "background-jobs", // Worker group for selective hosting + TrackProgress = false // Enable job progress tracking (default: false) +)] +public class MyHandler { ... } +``` + +### Concurrency + +Control how many messages are processed simultaneously: + +```csharp +[Queue(Concurrency = 10)] +public class BulkImportHandler { ... } +``` + +Each worker instance runs the specified number of concurrent consumers. Scale further by running multiple worker instances. + +### Retry Policies + +Three retry strategies are available: + +| Policy | Behavior | +| --- | --- | +| `None` | Failed messages are redelivered immediately | +| `Fixed` | Constant delay between retries | +| `Exponential` | Doubling delay with ±10% jitter (prevents thundering herd) | + +```csharp +[Queue(MaxAttempts = 5, RetryPolicy = QueueRetryPolicy.Exponential, RetryDelaySeconds = 2)] +public class FlakeyApiHandler { ... } +``` + +With `Exponential` and `RetryDelaySeconds = 2`, retries occur at approximately 2s, 4s, 8s, 16s (with jitter). + +### Result-Based Retry Decisions + +The worker uses your handler's `Result` status to decide what happens next: + +| Result Status | Action | +| --- | --- | +| Success, Created, Accepted, NoContent | Complete — message removed from queue | +| Error, Timeout, Unauthorized, Forbidden | Abandon — message retried up to `MaxAttempts` | +| NotFound, Invalid, CriticalError, Conflict, Gone | Dead-letter — message moved to dead-letter queue immediately | + +This means your handlers can make intelligent decisions about retry-ability: + +```csharp +[Queue] +public class PaymentHandler +{ + public async Task HandleAsync(ProcessPayment cmd, IPaymentGateway gateway, CancellationToken ct) + { + try + { + await gateway.ChargeAsync(cmd.Amount, cmd.CardToken, ct); + return Result.Ok(); + } + catch (GatewayTimeoutException) + { + return Result.Error("Gateway timeout — will retry"); + } + catch (InvalidCardException) + { + return Result.Invalid("Card declined — no retry"); + } + } +} +``` + +## Dead-Letter Queues + +Messages that exceed `MaxAttempts` or return a non-retryable result are moved to a dead-letter queue named `{queue}-dead-letter`. The original message is preserved along with metadata headers: + +| Header | Description | +| --- | --- | +| `fm-dead-letter-reason` | Why the message was dead-lettered | +| `fm-dead-lettered-at` | When it was dead-lettered (ISO 8601) | +| `fm-original-queue-name` | The source queue | +| `fm-dead-letter-dequeue-count` | Total processing attempts | + +## QueueContext + +When your handler runs inside a queue worker, a `QueueContext` is injected as a parameter. Use it for lifecycle control and progress reporting: + +```csharp +[Queue(TimeoutSeconds = 60)] +public class DataImportHandler +{ + public async Task HandleAsync( + ImportData cmd, + QueueContext ctx, + IImportService imports, + CancellationToken ct) + { + var batches = await imports.GetBatchesAsync(cmd.FileId, ct); + + foreach (var batch in batches) + { + await imports.ProcessBatchAsync(batch, ct); + + // Extend the visibility timeout for long-running work + await ctx.RenewTimeoutAsync(TimeSpan.FromSeconds(60), ct); + } + + return Result.Ok(); + } +} +``` + +### Available Properties and Methods + +```csharp +// Properties +ctx.QueueName // Name of the queue +ctx.MessageType // Type of the message being processed +ctx.DequeueCount // How many times this message has been dequeued +ctx.MaxAttempts // Maximum attempts before dead-lettering +ctx.EnqueuedAt // When the message was originally enqueued +ctx.JobId // Job ID (when TrackProgress is enabled) + +// Lifecycle methods +await ctx.CompleteAsync(ct); // Mark as successfully processed +await ctx.AbandonAsync(delay, ct); // Return to queue for retry +await ctx.RenewTimeoutAsync(TimeSpan.FromSeconds(30), ct); // Extend visibility timeout + +// Progress reporting (requires TrackProgress = true) +await ctx.ReportProgressAsync(ct); // Heartbeat +await ctx.ReportProgressAsync(75, "Processing batch 3/4", ct); // Percent + message +``` + +::: tip Auto-Complete +When `AutoComplete = true` (the default), the worker automatically completes or abandons the message based on your handler's result. You only need explicit lifecycle calls for advanced scenarios. +::: + +::: tip Auto-Renew Timeout +When `AutoRenewTimeout = true` (the default), the worker automatically renews the visibility timeout at 2/3 intervals. This prevents messages from becoming visible to other consumers while your handler is still processing. You only need manual `RenewTimeoutAsync` for very long-running handlers where you want explicit control. +::: + +## Progress Tracking + +For long-running jobs, enable progress tracking to give callers visibility into execution status: + +```csharp +[Queue(TrackProgress = true, Concurrency = 5)] +public class ReportGenerationHandler +{ + public async Task HandleAsync( + GenerateReport cmd, + QueueContext ctx, + IReportService reports, + CancellationToken ct) + { + var data = await reports.GatherDataAsync(cmd, ct); + await ctx.ReportProgressAsync(25, "Data gathered", ct); + + var analysis = await reports.AnalyzeAsync(data, ct); + await ctx.ReportProgressAsync(50, "Analysis complete", ct); + + await reports.RenderAsync(analysis, cmd.Format, ct); + await ctx.ReportProgressAsync(75, "Report rendered", ct); + + await reports.UploadAsync(cmd.OutputPath, ct); + return Result.Ok(); + } +} +``` + +### Querying Job State + +When progress tracking is enabled, `InvokeAsync` returns a job ID in the accepted result. Use the `IQueueJobStateStore` to query status: + +```csharp +var result = await mediator.InvokeAsync(new GenerateReport("monthly", "pdf"), ct); +var jobId = result.Value; // The job ID + +// Later, check progress +var stateStore = serviceProvider.GetRequiredService(); +var state = await stateStore.GetJobStateAsync(jobId, ct); + +Console.WriteLine($"Status: {state.Status}"); // Queued, Processing, Completed, Failed, Cancelled +Console.WriteLine($"Progress: {state.Progress}%"); // 0-100 +Console.WriteLine($"Message: {state.ProgressMessage}"); +``` + +### Job Cancellation + +Request cancellation of a tracked job: + +```csharp +await stateStore.RequestCancellationAsync(jobId, ct); +``` + +The worker polls for cancellation and triggers the `CancellationToken` passed to your handler. Your handler should check `ct.ThrowIfCancellationRequested()` at appropriate points. + +### State Store Providers + +By default, job state is stored in-memory. For production multi-node setups, use a persistent store: + +```csharp +// Redis (recommended for most deployments) +builder.Services.AddMediator() + .AddDistributedQueues() + .UseRedisJobState(opts => opts.KeyPrefix = "fm:jobs"); +``` + +See [Transport Providers](./distributed-transports) for setup details. + +## Worker Groups + +In larger deployments, you may want different nodes to process different queues — API servers handle web requests while dedicated workers process background jobs: + +```csharp +// Handler declares its group +[Queue(Group = "exports")] +public class ExportHandler { ... } + +[Queue(Group = "imports")] +public class ImportHandler { ... } +``` + +```csharp +// API server — enqueue only, no workers +builder.Services.AddMediator() + .AddDistributedQueues(opts => opts.WorkersEnabled = false); + +// Export worker — only processes export queues +builder.Services.AddMediator() + .AddDistributedQueues(opts => opts.Group = "exports"); + +// Import worker — only processes import queues +builder.Services.AddMediator() + .AddDistributedQueues(opts => opts.Group = "imports"); +``` + +You can also limit by specific queue names: + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues(opts => + { + opts.Queues = new() { "ProcessOrder", "ProcessPayment" }; + }); +``` + +## Resource Prefixing + +Use `ResourcePrefix` to namespace your queues, avoiding collisions in shared infrastructure: + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues(opts => opts.ResourcePrefix = "myapp-prod"); +``` + +This prefixes all queue names: `ProcessOrder` becomes `myapp-prod-ProcessOrder`. + +## Middleware Integration + +Queue-processed handlers run through the same middleware pipeline as local handlers. Middleware can detect queue context: + +```csharp +[Middleware] +public class ObservabilityMiddleware +{ + public Stopwatch Before(object message, HandlerExecutionInfo info, QueueContext? queueContext) + { + var source = queueContext is not null ? "queue" : "local"; + Log.Information("Handling {Type} (source: {Source})", message.GetType().Name, source); + return Stopwatch.StartNew(); + } + + public void After(object message, Stopwatch sw, QueueContext? queueContext) + { + Log.Information("Handled in {Elapsed}ms", sw.ElapsedMilliseconds); + } +} +``` + +`QueueContext` is `null` for non-queue invocations, so your middleware works naturally in both contexts. + +## Full Example + +Here's a complete example putting it all together: + +```csharp +// Message +public record ProcessOrder(string OrderId, string CustomerId, decimal Amount); + +// Handler +[Queue( + Concurrency = 3, + MaxAttempts = 5, + RetryPolicy = QueueRetryPolicy.Exponential, + TrackProgress = true, + Group = "order-processing")] +public class OrderProcessingHandler +{ + public async Task HandleAsync( + ProcessOrder cmd, + QueueContext ctx, + IOrderService orders, + ILogger logger, + CancellationToken ct) + { + logger.LogInformation("Processing order {OrderId}, attempt {Attempt}", + cmd.OrderId, ctx.DequeueCount); + + await ctx.ReportProgressAsync(10, "Validating order", ct); + var validation = await orders.ValidateAsync(cmd, ct); + if (!validation.IsValid) + return Result.Invalid(validation.Errors); + + await ctx.ReportProgressAsync(50, "Charging payment", ct); + var charge = await orders.ChargeAsync(cmd, ct); + if (!charge.Success) + return Result.Error("Payment failed — will retry"); + + await ctx.ReportProgressAsync(90, "Finalizing", ct); + await orders.FinalizeAsync(cmd, ct); + + return Result.Ok(); + } +} +``` + +```csharp +// DI registration +builder.Services.AddMediator() + .AddDistributedQueues(opts => + { + opts.WorkersEnabled = true; + opts.Group = "order-processing"; + }); +``` + +```csharp +// Enqueue from an API endpoint or another handler +var result = await mediator.InvokeAsync(new ProcessOrder("ORD-001", "CUST-42", 99.99m), ct); +// result.StatusCode == Accepted +// result.Value contains the job ID (when TrackProgress = true) +``` diff --git a/docs/guide/distributed-transports.md b/docs/guide/distributed-transports.md new file mode 100644 index 00000000..1cbb3935 --- /dev/null +++ b/docs/guide/distributed-transports.md @@ -0,0 +1,227 @@ +# Transport Providers + +Foundatio Mediator Distributed uses pluggable transport providers for both queues and pub/sub notifications. In development, the built-in in-memory transports work out of the box. For production, swap in a real provider with a single line of configuration. + +## In-Memory (Default) + +The default transports require no additional packages. They're registered automatically when you call `AddDistributedQueues()` or `AddDistributedNotifications()` without a specific provider. + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications(); +// Uses InMemoryQueueClient and InMemoryPubSubClient +``` + +::: warning Development Only +In-memory transports don't survive process restarts and don't work across multiple nodes. Use them for development and testing only. +::: + +## AWS (SQS + SNS) + +The AWS provider uses **SQS** for queues and **SNS + SQS** for pub/sub notifications. This is a production-grade setup that scales to millions of messages. + +### Installation + +```bash +dotnet add package Foundatio.Mediator.Distributed.Aws +``` + +### Unified Setup + +The simplest way to configure both queues and notifications: + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications() + .UseAws(); +``` + +This uses the default AWS SDK credential chain (environment variables, IAM roles, `~/.aws/credentials`, etc.). + +### With Options + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications() + .UseAws(opts => + { + // For LocalStack or custom endpoints + opts.ServiceUrl = "http://localhost:4566"; + opts.Region = "us-east-1"; + + // Queue options + opts.Queues.AutoCreateQueues = true; // Auto-create SQS queues (default: true) + opts.Queues.WaitTimeSeconds = 20; // Long-polling interval (default: 20) + + // Notification options + opts.Notifications.AutoCreate = true; // Auto-create SNS topics & SQS subscriptions + opts.Notifications.TopicName = "events"; // SNS topic name + opts.Notifications.QueuePrefix = "notifications"; // Per-node queue prefix + opts.Notifications.WaitTimeSeconds = 20; + opts.Notifications.CleanupOnDispose = true; // Remove per-node queue on shutdown + }); +``` + +### Separate Configuration + +Configure queues and notifications independently: + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications() + .UseAwsQueues(opts => + { + opts.AutoCreateQueues = true; + opts.WaitTimeSeconds = 20; + }) + .UseAwsNotifications(opts => + { + opts.TopicName = "my-events"; + opts.AutoCreate = true; + }); +``` + +### How AWS Queues Work + +Each `[Queue]` handler gets a dedicated SQS queue: + +- **Queue name:** Message type name (or custom `QueueName`), with optional `ResourcePrefix` +- **Dead-letter queue:** `{queue}-dead-letter` — created automatically +- **Long polling:** Uses SQS long polling (`WaitTimeSeconds`) for efficient message retrieval +- **Message format:** JSON body with headers as SQS message attributes +- **Visibility timeout:** Maps to SQS visibility timeout for message locking + +### How AWS Notifications Work + +Distributed notifications use an **SNS topic with per-node SQS queues**: + +1. One shared SNS topic for all distributed notifications +2. Each node creates its own SQS queue: `{QueuePrefix}-{HostId}` +3. The queue subscribes to the SNS topic with raw message delivery +4. Publishing sends to SNS, which fans out to all subscribed queues +5. On shutdown, the per-node queue is removed (when `CleanupOnDispose = true`) + +### LocalStack for Development + +Use [LocalStack](https://localstack.cloud/) to develop against AWS services locally: + +```bash +# docker-compose.yml +services: + localstack: + image: localstack/localstack + ports: + - "4566:4566" + environment: + - SERVICES=sqs,sns +``` + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications() + .UseAws(opts => opts.ServiceUrl = "http://localhost:4566"); +``` + +## Redis + +The Redis provider currently supports **job state storage** for progress tracking. Use it alongside an AWS (or other) queue provider to persist job state across nodes. + +### Installation + +```bash +dotnet add package Foundatio.Mediator.Distributed.Redis +``` + +### Job State Store + +```csharp +builder.Services.AddSingleton( + ConnectionMultiplexer.Connect("localhost")); + +builder.Services.AddMediator() + .AddDistributedQueues() + .UseRedisJobState(opts => + { + opts.KeyPrefix = "fm:jobs"; // Redis key prefix (default: "fm:jobs") + opts.DefaultExpiry = TimeSpan.FromHours(24); // Job state TTL (default: 24h) + }); +``` + +### How Redis Job State Works + +Job state is stored as Redis hashes with sorted sets for efficient querying: + +- **Job record:** `{KeyPrefix}:{jobId}` — hash with all state fields +- **Queue index:** `{KeyPrefix}:queue:{queueName}` — sorted set by timestamp +- **Status index:** `{KeyPrefix}:queue:{queueName}:status:{status}` — filtered views +- **TTL:** Records expire after `DefaultExpiry` (default 24 hours) + +All state transitions are atomic operations. + +## Custom Providers + +Implement `IQueueClient` for custom queue transports and `IPubSubClient` for custom pub/sub transports: + +```csharp +public interface IQueueClient : IAsyncDisposable +{ + 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 : IAsyncDisposable +{ + Task PublishAsync(string topic, IReadOnlyList messages, CancellationToken ct = default); + Task SubscribeAsync(string topic, Func handler, CancellationToken ct = default); + Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken ct = default); +} +``` + +Register your custom implementation before calling `AddDistributedQueues()` or `AddDistributedNotifications()`: + +```csharp +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications(); +``` + +The registration extensions skip adding the default in-memory implementations when a provider is already registered. + +## Combining Providers + +Mix and match providers based on your infrastructure: + +```csharp +builder.Services.AddMediator() + .AddDistributedQueues() // Queues via AWS SQS + .AddDistributedNotifications() // Notifications via AWS SNS+SQS + .UseAws(opts => + { + opts.ServiceUrl = config["AWS:ServiceURL"]; + }) + .UseRedisJobState(); // Job state via Redis +``` + +## Infrastructure Initialization + +When using real transport providers, the distributed infrastructure needs to create queues and topics before workers start processing. This happens automatically during startup: + +1. **Warmup:** Creates the first queue/topic to absorb connection overhead +2. **Batch creation:** Creates remaining infrastructure concurrently +3. **Ready signal:** Workers wait for infrastructure to be ready before polling + +This is transparent — your code doesn't need to manage initialization order. diff --git a/docs/index.md b/docs/index.md index 68372359..818c0ad4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ features: link: /guide/what-is-foundatio-mediator - icon: 🔗 title: Compose with Events - details: Publish an event and any number of handlers react — without knowing about each other. Add new behavior to your app without modifying existing code. + details: Publish an event and any number of handlers react — without knowing about each other. Return tuples to automatically cascade follow-on events through your system. link: /guide/events-and-notifications - icon: 🧪 title: Easy to Test @@ -57,10 +57,10 @@ features: title: Middleware Pipeline details: Before/After/Finally/Execute hooks with state passing and short-circuiting capabilities. link: /guide/middleware - - icon: 🔄 - title: Automatic Message Cascading - details: Return tuples to automatically publish additional messages in sequence — ideal for event-driven workflows. - link: /guide/cascading-messages + - icon: � + title: Scale Out When You're Ready + details: Need to offload work to background queues or broadcast events across nodes? Add an attribute or marker interface — same handlers, same middleware, now distributed. + link: /guide/distributed-overview - icon: 🔒 title: Compile-Time Safety & Debugging details: Comprehensive diagnostics catch errors early. Short, simple call stacks with minimal indirection make debugging straightforward. @@ -96,3 +96,16 @@ Turn your message handlers into API endpoints automatically: app.MapMediatorEndpoints(); // That's it — routes, methods, and parameter binding are all generated for you. ``` + +Go distributed — ready to scale out? Offload work to a background queue with one attribute: + +```csharp +public record SendEmail(string To, string Subject, string Body); + +[Queue] +public class SendEmailHandler(IEmailService email) +{ + public async Task HandleAsync(SendEmail msg, CancellationToken ct) + => await email.SendAsync(msg.To, msg.Subject, msg.Body, ct); +} +``` 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/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..0038c3a1 100644 --- a/samples/CleanArchitectureSample/README.md +++ b/samples/CleanArchitectureSample/README.md @@ -24,6 +24,12 @@ 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 | +| **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 @@ -31,10 +37,12 @@ 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 +│ │ ├── 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 @@ -70,10 +78,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 + Redis) +│ └── Program.cs +│ +├── ServiceDefaults/ # Aspire service defaults (OpenTelemetry, health checks) +│ └── Extensions.cs +│ └── Web/ # SvelteKit SPA frontend ``` @@ -401,7 +415,47 @@ 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 +builder.Services.AddMediator() + .AddDistributedQueues() + .AddDistributedNotifications() + .UseAws(aws => aws.ServiceUrl = builder.Configuration["AWS:ServiceURL"]!) + .UseRedisJobState(); +``` + +### 12. Result Pattern All handlers return `Result` for business logic outcomes instead of throwing exceptions: @@ -417,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: @@ -445,49 +558,57 @@ Common.Module (no module dependencies) - .NET 10 SDK - Node.js 20+ (for the frontend) +- Docker (for Aspire, LocalStack, and Redis) ### Quick Start -1. **Install frontend dependencies** (first time only): +The sample runs via Aspire, which orchestrates separate API and worker processes with LocalStack (SQS/SNS) and Redis: + +```bash +cd samples/CleanArchitectureSample/src/AppHost +dotnet run +``` - ```bash - cd samples/CleanArchitectureSample/src/Web - npm install - ``` +This starts: -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` +- **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) +- **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 -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). @@ -506,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. diff --git a/samples/CleanArchitectureSample/src/Api/Api.csproj b/samples/CleanArchitectureSample/src/Api/Api.csproj index a3cd010c..f1ee52fd 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 @@ -28,13 +24,20 @@ - + + + + + + + + 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/Handlers/ClientEventStreamHandler.cs b/samples/CleanArchitectureSample/src/Api/Handlers/EventHandler.cs similarity index 68% rename from samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs rename to samples/CleanArchitectureSample/src/Api/Handlers/EventHandler.cs index d2b87f0b..21768522 100644 --- a/samples/CleanArchitectureSample/src/Api/Handlers/ClientEventStreamHandler.cs +++ b/samples/CleanArchitectureSample/src/Api/Handlers/EventHandler.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/InfrastructureExtensions.cs b/samples/CleanArchitectureSample/src/Api/InfrastructureExtensions.cs new file mode 100644 index 00000000..91760924 --- /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.Stats.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 f8dd949c..9bed1708 100644 --- a/samples/CleanArchitectureSample/src/Api/Program.cs +++ b/samples/CleanArchitectureSample/src/Api/Program.cs @@ -1,6 +1,8 @@ using Common.Module; using Foundatio.Mediator; -using Microsoft.AspNetCore.Authentication.Cookies; +using Foundatio.Mediator.Distributed; +using Foundatio.Mediator.Distributed.Aws; +using Foundatio.Mediator.Distributed.Redis; using Orders.Module; using Products.Module; using Reports.Module; @@ -8,65 +10,56 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpContextAccessor(); -builder.Services.AddOpenApi(); +var options = AppOptions.Parse(args); -// Simple cookie authentication for the sample -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; - // 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(); +builder.AddServiceDefaults(); +builder.AddRedisAndCaching(); -// Add module services -// Order matters: Common.Module provides cross-cutting services that other modules may depend on +// ── Foundatio.Mediator ── +builder.Services.AddMediator() + .AddDistributedQueues(opts => + { + 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(); -// Cross-module event handlers (AuditEventHandler, NotificationEventHandler) are now -// in Common.Module and will be discovered automatically via the source generator +if (options.IsApiEnabled) +{ + builder.Services.AddHttpContextAccessor(); + builder.Services.AddOpenApi(); + builder.AddSampleAuthentication(); +} var app = builder.Build(); -// Serve static files from the SPA -app.UseDefaultFiles(); -app.MapStaticAssets(); +app.LogStartupDiagnostics(options); +app.MapHealthCheckEndpoints(); +app.UseSuppressInstrumentation("/api/queues/queues", "/api/queues/job-dashboard", "/api/events"); -app.MapOpenApi(); -app.MapScalarApiReference(); +if (options.IsApiEnabled) +{ + app.UseDefaultFiles(); + app.MapStaticAssets(); -app.UseHttpsRedirection(); + app.MapOpenApi(); + app.MapScalarApiReference(); -app.UseAuthentication(); -app.UseAuthorization(); + app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); -// Map module endpoints - discovers and maps all endpoint modules from referenced assemblies -app.MapMediatorEndpoints(); - -// SPA fallback - serves index.html for client-side routing -app.MapFallbackToFile("/index.html"); + 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 d3841201..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:58702;http://localhost:58703" + "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 new file mode 100644 index 00000000..62509134 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Foundatio.Mediator.Distributed.Aws": "Debug" + } + } +} 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..4fa186e5 --- /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..93d9bacd --- /dev/null +++ b/samples/CleanArchitectureSample/src/AppHost/Program.cs @@ -0,0 +1,50 @@ +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"); + +// 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") + .WithHttpEndpoint() + .WithHttpsEndpoint() + .WithExternalHttpEndpoints() + .WithReplicas(3) + .WaitFor(localstack) + .WaitFor(redis) + .WithReference(localstack.GetEndpoint("main")) + .WithReference(redis) + .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("main")) + // 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") + .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..536fbe09 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/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/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..ff0e8de2 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(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 new file mode 100644 index 00000000..48ef90d2 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/DemoExportJobHandler.cs @@ -0,0 +1,60 @@ +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, 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) + { + 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()))); + + logger.LogInformation("Starting demo export job ({Steps} steps, ~{Delay}ms each)", steps, baseDelay); + + for (int i = 1; i <= steps; i++) + { + 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); + + 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); + } + + 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..220daef7 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(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 new file mode 100644 index 00000000..ed64adba --- /dev/null +++ b/samples/CleanArchitectureSample/src/Common.Module/Handlers/QueueDashboardHandler.cs @@ -0,0 +1,234 @@ +using Common.Module.Messages; +using Common.Module.Middleware; +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. +/// +/// 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] +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, + 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(); + + 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]; + statsMap.TryGetValue(worker.QueueName, out var stats); + tasks[i] = ToSummaryAsync(worker, stats, ct); + } + + 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"); + + 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) + { + 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 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(), + CounterStats = counterStats + }; + } + + 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) + { + QueueCounterStats? counterStats = null; + long? processingCount = null; + if (_stateStore is not null) + { + try { counterStats = await _stateStore.GetCounterStatsAsync(worker.QueueName, TimeSpan.FromHours(24), 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 */ } + } + } + + 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, + MessageType = worker.MessageTypeName, + Concurrency = worker.Concurrency, + MaxAttempts = worker.MaxAttempts, + RetryPolicy = worker.RetryPolicy.ToString(), + TrackProgress = worker.TrackProgress, + Description = worker.Description, + IsRunning = worker.Stats.WorkerRegistered ? worker.Stats.IsRunning : null, + MessagesProcessed = counterStats?.Totals.GetValueOrDefault("processed") ?? worker.Stats.MessagesProcessed, + MessagesFailed = counterStats?.Totals.GetValueOrDefault("failed") ?? worker.Stats.MessagesFailed, + MessagesDeadLettered = counterStats?.Totals.GetValueOrDefault("dead_lettered") ?? worker.Stats.MessagesDeadLettered, + ActiveCount = stats?.ActiveCount ?? 0, + DeadLetterCount = counterStats?.Totals.GetValueOrDefault("dead_lettered") ?? stats?.DeadLetterCount ?? 0, + InFlightCount = processingCount ?? stats?.InFlightCount ?? 0, + CounterStats = counterStatsView + }; + } + + 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, + Attempt = s.Attempt, + 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..1b71ee58 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Common.Module/Messages/QueueDashboardMessages.cs @@ -0,0 +1,79 @@ +namespace Common.Module.Messages; + +// ── Queue Dashboard Queries ── + +public record GetQueues; + +public record GetQueue(string QueueName); + +public record GetJobDashboard(string QueueName, int? RecentTerminalCount = 5); + +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 MaxAttempts { get; init; } + public required string RetryPolicy { get; init; } + public bool TrackProgress { 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; } + public long ActiveCount { get; init; } + public long DeadLetterCount { get; init; } + public long InFlightCount { get; init; } + public CounterStatsView? CounterStats { 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 int Attempt { 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 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); + +// ── 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..e1a69d38 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..a6e32a5b 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..3c71f0f9 --- /dev/null +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/Extensions.cs @@ -0,0 +1,154 @@ +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 => + { + // 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 != "/api/queues/queues" + && ctx.Request.Path != "/api/queues/job-dashboard"; + }) + .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") + .AddRedisInstrumentation(); + + // 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.Source.Name == "OpenTelemetry.Instrumentation.StackExchangeRedis" && activity.Parent is null))); + }); + + 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 MapHealthCheckEndpoints(this WebApplication app) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + 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 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 +{ + 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..8223b0bb --- /dev/null +++ b/samples/CleanArchitectureSample/src/ServiceDefaults/ServiceDefaults.csproj @@ -0,0 +1,27 @@ + + + + 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..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.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.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": "^0.575.0", - "svelte": "^5.53.6", - "svelte-check": "^4.4.4", + "lucide-svelte": "^1.0.1", + "svelte": "^5.55.2", + "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.8" } } 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..a7be4867 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Web/src/lib/api/queues.ts @@ -0,0 +1,26 @@ +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, recentTerminalCount: number = 5) => + api.getJSON( + `/api/queues/job-dashboard?queueName=${encodeURIComponent(queueName)}&recentTerminalCount=${recentTerminalCount}` + ), + + 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 }), + + /** 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/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/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..4e1fee38 --- /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/stores/eventstream.svelte.ts b/samples/CleanArchitectureSample/src/Web/src/lib/stores/eventstream.svelte.ts index 49b43c16..ea5fe031 100644 --- a/samples/CleanArchitectureSample/src/Web/src/lib/stores/eventstream.svelte.ts +++ b/samples/CleanArchitectureSample/src/Web/src/lib/stores/eventstream.svelte.ts @@ -43,12 +43,21 @@ export type ClientEvent = { data: Record; }; +export type EventEntry = { + id: number; + timestamp: Date; + type: string; + category: 'order' | 'product'; + action: 'created' | 'updated' | 'deleted'; + data: Record; +}; + type EventCallback = (event: T) => void; /** * SSE-based event service. Connects to the server's /events/stream endpoint * using the EventSource API and dispatches domain events to registered callbacks. - * Replaces the previous SignalR-based implementation. + * Buffers events globally so they are captured even when the events page is not visible. */ class EventStreamService { private eventSource: EventSource | null = null; @@ -59,8 +68,22 @@ class EventStreamService { private productUpdatedCallbacks: EventCallback[] = []; private productDeletedCallbacks: EventCallback[] = []; private reconnectTimer: ReturnType | null = null; + private nextId = 0; + private maxEvents = 200; isConnected = $state(false); + events = $state([]); + paused = $state(false); + + private addEvent(type: string, category: EventEntry['category'], action: EventEntry['action'], data: Record) { + if (this.paused) return; + const entry: EventEntry = { id: this.nextId++, timestamp: new Date(), type, category, action, data }; + this.events = [entry, ...this.events].slice(0, this.maxEvents); + } + + clearEvents() { + this.events = []; + } start() { if (this.eventSource) return; @@ -68,7 +91,7 @@ class EventStreamService { } private connect() { - this.eventSource = new EventSource('/events/stream'); + this.eventSource = new EventSource('/api/events'); this.eventSource.addEventListener('message', (e: MessageEvent) => { try { @@ -106,21 +129,27 @@ class EventStreamService { switch (eventType) { case 'OrderCreated': + this.addEvent('OrderCreated', 'order', 'created', { orderId: (data as unknown as OrderCreatedEvent).orderId, customerId: (data as unknown as OrderCreatedEvent).customerId, amount: (data as unknown as OrderCreatedEvent).amount }); this.orderCreatedCallbacks.forEach((cb) => cb(data as unknown as OrderCreatedEvent)); break; case 'OrderUpdated': + this.addEvent('OrderUpdated', 'order', 'updated', { orderId: (data as unknown as OrderUpdatedEvent).orderId, amount: (data as unknown as OrderUpdatedEvent).amount, status: (data as unknown as OrderUpdatedEvent).status }); this.orderUpdatedCallbacks.forEach((cb) => cb(data as unknown as OrderUpdatedEvent)); break; case 'OrderDeleted': + this.addEvent('OrderDeleted', 'order', 'deleted', { orderId: (data as unknown as OrderDeletedEvent).orderId }); this.orderDeletedCallbacks.forEach((cb) => cb(data as unknown as OrderDeletedEvent)); break; case 'ProductCreated': + this.addEvent('ProductCreated', 'product', 'created', { productId: (data as unknown as ProductCreatedEvent).productId, name: (data as unknown as ProductCreatedEvent).name, price: (data as unknown as ProductCreatedEvent).price }); this.productCreatedCallbacks.forEach((cb) => cb(data as unknown as ProductCreatedEvent)); break; case 'ProductUpdated': + this.addEvent('ProductUpdated', 'product', 'updated', { productId: (data as unknown as ProductUpdatedEvent).productId, name: (data as unknown as ProductUpdatedEvent).name, price: (data as unknown as ProductUpdatedEvent).price, status: (data as unknown as ProductUpdatedEvent).status }); this.productUpdatedCallbacks.forEach((cb) => cb(data as unknown as ProductUpdatedEvent)); break; case 'ProductDeleted': + this.addEvent('ProductDeleted', 'product', 'deleted', { productId: (data as unknown as ProductDeletedEvent).productId }); this.productDeletedCallbacks.forEach((cb) => cb(data as unknown as ProductDeletedEvent)); break; } 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..0dc44ac5 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Web/src/lib/types/queue.ts @@ -0,0 +1,67 @@ +export interface QueueSummary { + queueName: string; + messageType: string; + concurrency: number; + maxAttempts: number; + retryPolicy: string; + trackProgress: boolean; + description: string | null; + isRunning: boolean | null; + messagesProcessed: number; + messagesFailed: number; + messagesDeadLettered: number; + activeCount: number; + deadLetterCount: number; + inFlightCount: number; + counterStats: CounterStats | null; +} + +export type JobStatus = 'Queued' | 'Processing' | 'Completed' | 'Failed' | 'Cancelled'; + +export interface JobSummary { + jobId: string; + queueName: string; + messageType: string; + status: JobStatus; + progress: number; + progressMessage: string | null; + attempt: number; + createdUtc: string; + startedUtc: string | null; + completedUtc: string | null; + errorMessage: string | null; +} + +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 { + 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/events/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/events/+page.svelte index 876c0d19..2fa5ed89 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/events/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/events/+page.svelte @@ -1,34 +1,9 @@ @@ -101,11 +51,11 @@ {eventStream.isConnected ? 'Connected' : 'Disconnected'} | - {events.length} event{events.length !== 1 ? 's' : ''} - - @@ -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/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte index 5695c460..11c7570d 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/orders/+page.svelte @@ -72,15 +72,14 @@ } } - // 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 SPA navigations back to this page + afterNavigate(({ type }) => { + // Skip the initial navigation — onMount handles the first load + if (type === 'enter') return; + 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) => { diff --git a/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte b/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte index cfdf7a1d..5fedf56a 100644 --- a/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte +++ b/samples/CleanArchitectureSample/src/Web/src/routes/products/+page.svelte @@ -72,15 +72,14 @@ } } - // 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 SPA navigations back to this page + afterNavigate(({ type }) => { + // Skip the initial navigation — onMount handles the first load + if (type === 'enter') return; + 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) => { 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..86103421 --- /dev/null +++ b/samples/CleanArchitectureSample/src/Web/src/routes/queues/+page.svelte @@ -0,0 +1,358 @@ + + + + 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} + +
QueueThroughputQueuedIn FlightDead Letter
+ {worker.queueName} + +
+ + + +
+
{worker.activeCount.toLocaleString()}{worker.inFlightCount.toLocaleString()}{worker.deadLetterCount.toLocaleString()}
+
+ + + {#if selectedQueue} + {@const queueWorker = workers.find((w) => w.queueName === selectedQueue)} +
+
+
+

{selectedQueue}

+

+ Retry: {queueWorker?.retryPolicy} · Max attempts: {queueWorker?.maxAttempts} +

+
+ +
+ + {#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} + + {#if job.attempt > 1} + + Retry #{job.attempt - 1} + + {/if} + {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} + + {#if job.attempt > 1} + + Retry #{job.attempt - 1} + + {/if} + {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/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/AwsServiceExtensions.cs b/src/Foundatio.Mediator.Distributed.Aws/AwsServiceExtensions.cs new file mode 100644 index 00000000..09d00cc6 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/AwsServiceExtensions.cs @@ -0,0 +1,159 @@ +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Amazon.SQS; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// Extension methods for configuring AWS SQS/SNS transports on . +/// +public static class AwsBuilderExtensions +{ + /// + /// Configures both SQS queues and SNS/SQS pub/sub as the distributed transports. + /// When is set, the SQS and SNS SDK + /// clients are automatically registered; otherwise you must register + /// IAmazonSQS and IAmazonSimpleNotificationService before calling this. + /// + /// The mediator builder. + /// Optional configuration for . + /// The mediator builder for chaining. + /// + /// + /// // LocalStack / dev — SDK clients auto-registered + /// services.AddMediator() + /// .AddDistributedQueues() + /// .AddDistributedNotifications() + /// .UseAws(aws => aws.ServiceUrl = "http://localhost:4566"); + /// + /// // Production — SDK clients pre-registered via AddAWSService + /// services.AddAWSService<IAmazonSQS>(); + /// services.AddAWSService<IAmazonSimpleNotificationService>(); + /// services.AddMediator() + /// .AddDistributedQueues() + /// .AddDistributedNotifications() + /// .UseAws(aws => aws.Queues.AutoCreateQueues = false); + /// + /// + public static IMediatorBuilder UseAws( + this IMediatorBuilder builder, + Action? configure = null) + { + var options = new AwsTransportOptions(); + configure?.Invoke(options); + + if (!string.IsNullOrEmpty(options.ServiceUrl)) + RegisterSdkClients(builder.Services, options); + + builder.UseAwsQueues(opts => + { + opts.AutoCreateQueues = options.Queues.AutoCreateQueues; + opts.WaitTimeSeconds = options.Queues.WaitTimeSeconds; + }); + + builder.UseAwsNotifications(opts => + { + opts.TopicName = options.Notifications.TopicName; + opts.TopicArn = options.Notifications.TopicArn; + opts.AutoCreate = options.Notifications.AutoCreate; + opts.QueuePrefix = options.Notifications.QueuePrefix; + opts.WaitTimeSeconds = options.Notifications.WaitTimeSeconds; + opts.CleanupOnDispose = options.Notifications.CleanupOnDispose; + }); + + return builder; + } + + /// + /// Registers as the implementation. + /// Requires IAmazonSQS to be registered in DI. + /// + /// The mediator builder. + /// Optional configuration for . + /// The mediator builder for chaining. + /// + /// + /// services.AddAWSService<IAmazonSQS>(); + /// services.AddMediator() + /// .AddDistributedQueues() + /// .UseAwsQueues(opts => opts.AutoCreateQueues = false); + /// + /// + public static IMediatorBuilder UseAwsQueues( + this IMediatorBuilder builder, + Action? configure = null) + { + var services = builder.Services; + var options = new SqsQueueClientOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(); + + return builder; + } + + /// + /// Registers as the implementation. + /// Requires IAmazonSimpleNotificationService and IAmazonSQS to be registered in DI. + /// + /// The mediator builder. + /// Optional configuration for . + /// The mediator builder for chaining. + /// + /// + /// services.AddAWSService<IAmazonSQS>(); + /// services.AddAWSService<IAmazonSimpleNotificationService>(); + /// services.AddMediator() + /// .AddDistributedNotifications() + /// .UseAwsNotifications(); + /// + /// + public static IMediatorBuilder UseAwsNotifications( + this IMediatorBuilder builder, + Action? configure = null) + { + var services = builder.Services; + var options = new SqsPubSubClientOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(sp => new SqsPubSubClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + options, + sp.GetRequiredService(), + sp.GetRequiredService>())); + + return builder; + } + + private static void RegisterSdkClients(IServiceCollection services, AwsTransportOptions options) + { + var credentials = options.Credentials + ?? new BasicAWSCredentials("test", "test"); + + if (!services.Any(sd => sd.ServiceType == typeof(IAmazonSQS))) + { + var sqsConfig = new AmazonSQSConfig + { + ServiceURL = options.ServiceUrl, + AuthenticationRegion = options.Region + }; + services.AddSingleton(_ => new AmazonSQSClient(credentials, sqsConfig)); + } + + if (!services.Any(sd => sd.ServiceType == typeof(IAmazonSimpleNotificationService))) + { + var snsConfig = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = options.ServiceUrl, + AuthenticationRegion = options.Region + }; + services.AddSingleton( + _ => new AmazonSimpleNotificationServiceClient(credentials, snsConfig)); + } + } +} diff --git a/src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs new file mode 100644 index 00000000..b66ff2ce --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/AwsTransportOptions.cs @@ -0,0 +1,41 @@ +using Amazon.Runtime; + +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// Unified options for configuring both SQS queues and SNS/SQS pub/sub transports. +/// Use with for a single-call configuration +/// that registers both queue and notification transports. +/// +public class AwsTransportOptions +{ + /// + /// The AWS service URL (e.g. "http://localhost:4566" for LocalStack). + /// When set, the SQS and SNS SDK clients are automatically registered with this endpoint. + /// When null, you must register IAmazonSQS and IAmazonSimpleNotificationService + /// in DI before calling UseAws(). + /// + public string? ServiceUrl { get; set; } + + /// + /// The AWS region to use when is set. Default is "us-east-1". + /// + public string Region { get; set; } = "us-east-1"; + + /// + /// Optional AWS credentials. When null and is set, + /// dummy credentials ("test"/"test") are used (suitable for LocalStack). + /// When is not set, this is ignored (SDK clients must be pre-registered). + /// + public AWSCredentials? Credentials { get; set; } + + /// + /// Options for the SQS queue client. See . + /// + public SqsQueueClientOptions Queues { get; set; } = new(); + + /// + /// Options for the SNS/SQS pub/sub client. See . + /// + public SqsPubSubClientOptions Notifications { get; set; } = new(); +} 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..04ce5e42 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/Foundatio.Mediator.Distributed.Aws.csproj @@ -0,0 +1,18 @@ + + + + + + net10.0 + latest + enable + Foundatio.Mediator.Distributed.Aws + + + + + + + + + diff --git a/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs new file mode 100644 index 00000000..f39f4a68 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClient.cs @@ -0,0 +1,415 @@ +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 SqsPubSubClient : IPubSubClient, IAsyncDisposable +{ + private readonly IAmazonSimpleNotificationService _sns; + private readonly IAmazonSQS _sqs; + private readonly SqsPubSubClientOptions _options; + private readonly string _hostId; + private readonly string? _resourcePrefix; + 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 SqsPubSubClient( + IAmazonSimpleNotificationService sns, + IAmazonSQS sqs, + SqsPubSubClientOptions options, + DistributedNotificationOptions notificationOptions, + ILogger logger) + { + _sns = sns; + _sqs = sqs; + _options = options; + _hostId = notificationOptions.HostId; + _resourcePrefix = notificationOptions.ResourcePrefix; + _logger = logger; + } + + /// + public async Task PublishAsync(string topic, IReadOnlyList messages, CancellationToken cancellationToken = default) + { + var topicArn = await GetOrCreateTopicArnAsync(topic, cancellationToken).ConfigureAwait(false); + + foreach (var message in messages) + { + // 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); + + 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.SubscriptionArn, setup.TopicArn, cts, pollTask, + _sns, _options, _logger); + + _activeSubscriptions.Add(handle); + + return handle; + } + + /// + /// Ensures per-node subscription infrastructure is created for a topic. + /// Ensures the shared per-node SQS queue exists (created once, cached). + /// + private async Task<(string QueueName, string QueueUrl, string QueueArn)> EnsureSharedQueueAsync(CancellationToken cancellationToken) + { + var current = _sharedQueue; + if (current is not null) + return current.Value; + + await _queueSetupLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + current = _sharedQueue; + if (current is not null) + return current.Value; + + var queuePrefix = string.IsNullOrEmpty(_resourcePrefix) + ? _options.QueuePrefix + : $"{_resourcePrefix}-{_options.QueuePrefix}"; + var queueName = $"{queuePrefix}-{_hostId}"; + var stepSw = System.Diagnostics.Stopwatch.StartNew(); + + var createResponse = await _sqs.CreateQueueAsync(new CreateQueueRequest + { + QueueName = queueName + }, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("EnsureSharedQueue: CreateQueue completed in {ElapsedMs}ms", stepSw.ElapsedMilliseconds); + stepSw.Restart(); + + // Get the queue ARN — needed for the SNS subscription policy + var queueAttrs = await _sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest + { + QueueUrl = createResponse.QueueUrl, + AttributeNames = ["QueueArn"] + }, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("EnsureSharedQueue: GetQueueAttributes completed in {ElapsedMs}ms", stepSw.ElapsedMilliseconds); + + _sharedQueue = (queueName, createResponse.QueueUrl, queueAttrs.QueueARN); + return _sharedQueue.Value; + } + finally + { + _queueSetupLock.Release(); + } + } + + /// + /// Ensures per-node subscription infrastructure is created for a topic. + /// Creates the SNS topic, reuses the shared 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); + var queue = await EnsureSharedQueueAsync(cancellationToken).ConfigureAwait(false); + + // Subscribe the SQS queue to the SNS topic + var subscribeResponse = await _sns.SubscribeAsync(new SubscribeRequest + { + TopicArn = topicArn, + Protocol = "sqs", + Endpoint = queue.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, queue.QueueName, subscriptionArn); + + var setup = new SubscriptionSetup(topicArn, queue.QueueName, queue.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; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + if (_options.AutoCreate) + { + var response = await _sns.CreateTopicAsync(new CreateTopicRequest + { + Name = topic + }, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("CreateTopic {Topic} completed in {ElapsedMs}ms", topic, sw.ElapsedMilliseconds); + + _topicArnCache[topic] = response.TopicArn; + return response.TopicArn; + } + + // Find existing topic + var findResponse = await _sns.FindTopicAsync(topic).ConfigureAwait(false); + _logger.LogDebug("FindTopic {Topic} completed in {ElapsedMs}ms", topic, sw.ElapsedMilliseconds); + + 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 async Task EnsureTopicsAsync(IReadOnlyList topics, CancellationToken cancellationToken = default) + { + if (topics.Count == 0) + return; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + // 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.Name, cancellationToken)).ToArray(); + + await Task.WhenAll(topicTasks).ConfigureAwait(false); + var queue = await queueTask.ConfigureAwait(false); + + _logger.LogInformation("EnsureTopics: queue + topics created in {ElapsedMs}ms", sw.ElapsedMilliseconds); + + var topicArns = topicTasks.Select(t => t.Result).ToList(); + + // 2. Set a single SQS policy allowing ALL SNS topics to send messages + var arnList = string.Join("\", \"", topicArns); + var policy = $$""" + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "sns.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": "{{queue.QueueArn}}", + "Condition": { + "ArnEquals": { "aws:SourceArn": ["{{arnList}}"] } + } + }] + } + """; + + await _sqs.SetQueueAttributesAsync(new SetQueueAttributesRequest + { + QueueUrl = queue.QueueUrl, + Attributes = new Dictionary + { + ["Policy"] = policy + } + }, cancellationToken).ConfigureAwait(false); + + _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.Name, cancellationToken))).ConfigureAwait(false); + + _logger.LogInformation("EnsureTopics: complete in {ElapsedMs}ms ({Count} topics)", sw.ElapsedMilliseconds, topics.Count); + } + + 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); + + // Clean up the shared per-node SQS queue if configured. + // Individual SubscriptionHandle disposals unsubscribe from SNS, but the + // shared queue is owned by this client and must be deleted here. + if (_sharedQueue is not null && _options.CleanupOnDispose) + { + try + { + await _sqs.DeleteQueueAsync(new DeleteQueueRequest + { + QueueUrl = _sharedQueue.Value.QueueUrl + }).ConfigureAwait(false); + + _logger.LogInformation("Deleted shared per-node SQS queue {QueueName}", _sharedQueue.Value.QueueName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete shared per-node SQS queue {QueueName}", _sharedQueue.Value.QueueName); + } + } + } + + private sealed class MessageEnvelope + { + public string Body { get; set; } = string.Empty; + public Dictionary? Headers { get; set; } + } + + private sealed class SubscriptionHandle( + string subscriptionArn, + string topicArn, + CancellationTokenSource cts, + Task pollTask, + IAmazonSimpleNotificationService sns, + SqsPubSubClientOptions 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); + } + + // 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 new file mode 100644 index 00000000..9735bc58 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsPubSubClientOptions.cs @@ -0,0 +1,43 @@ +namespace Foundatio.Mediator.Distributed.Aws; + +/// +/// Options for configuring the SNS+SQS pub/sub client. +/// +public class SqsPubSubClientOptions +{ + /// + /// 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..91275b4d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClient.cs @@ -0,0 +1,360 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Logging; + +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 ILogger _logger; + private readonly ConcurrentDictionary _queueUrlCache = new(); + private readonly ConcurrentDictionary _dlqNotFound = new(); + + 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; + } + + /// + /// SQS allows a maximum of 10 message attributes per message. + /// + private const int MaxSqsMessageAttributes = 10; + + public async Task SendAsync(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]; + + ValidateHeaderCount(entry.Headers, queueName); + + 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); + _dlqNotFound.TryRemove(dlqName, out _); + 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); + + try + { + 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) + { + if (_queueUrlCache.TryGetValue(queueName, out var cached)) + return cached; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + if (_options.AutoCreateQueues) + { + var createResponse = await _sqs.CreateQueueAsync(new CreateQueueRequest + { + QueueName = queueName + }, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("CreateQueue {QueueName} completed in {ElapsedMs}ms", queueName, sw.ElapsedMilliseconds); + + _queueUrlCache[queueName] = createResponse.QueueUrl; + return createResponse.QueueUrl; + } + + var response = await _sqs.GetQueueUrlAsync(new GetQueueUrlRequest + { + QueueName = queueName + }, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("GetQueueUrl {QueueName} completed in {ElapsedMs}ms", queueName, sw.ElapsedMilliseconds); + + _queueUrlCache[queueName] = response.QueueUrl; + return response.QueueUrl; + } + + /// + public async Task EnsureQueuesAsync(IReadOnlyList queues, CancellationToken cancellationToken = default) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + + await Task.WhenAll(queues.Select(q => GetQueueUrlAsync(q.Name, cancellationToken))).ConfigureAwait(false); + + _logger.LogInformation("EnsureQueues: {Count} queues ready in {ElapsedMs}ms", queues.Count, sw.ElapsedMilliseconds); + } + + /// + public async Task> GetQueueStatsAsync(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + { + var results = new List(queueNames.Count); + foreach (var queueName in queueNames) + { + 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 (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 + { + 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; + } + catch + { + // DLQ doesn't exist yet — remember so we don't retry on every poll + _dlqNotFound[dlqName] = true; + } + } + + results.Add(new QueueStats + { + QueueName = queueName, + ActiveCount = activeCount, + InFlightCount = inFlightCount, + DeadLetterCount = deadLetterCount + }); + } + + return results; + } + + 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.Aws/SqsQueueClientOptions.cs b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs new file mode 100644 index 00000000..f8f9d797 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Aws/SqsQueueClientOptions.cs @@ -0,0 +1,20 @@ +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; +} 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..cec91da0 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/Foundatio.Mediator.Distributed.Redis.csproj @@ -0,0 +1,17 @@ + + + + + + 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..173e741c --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisJobStateStoreOptions.cs @@ -0,0 +1,29 @@ +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"; + + /// + /// Optional prefix applied before for app-level scoping. + /// When set, all Redis keys become "{ResourcePrefix}:{KeyPrefix}:...". + /// When null or empty (default), only is used. + /// + /// + /// Use this to isolate multiple applications sharing the same Redis instance + /// (e.g., "myapp" produces keys like "myapp:fm:jobs:..."). + /// + public string? ResourcePrefix { get; set; } + + /// + /// 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..aa69dc1c --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisQueueJobStateStore.cs @@ -0,0 +1,358 @@ +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; + private readonly TimeProvider _timeProvider; + private readonly string _keyPrefix; + + 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}"; + } + + public async Task SetJobStateAsync(QueueJobState state, TimeSpan? expiry = null, CancellationToken cancellationToken = default) + { + 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[] + { + 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", 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), + 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, 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); + } + + // 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); + } + } + + 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 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) + { + var db = _redis.GetDatabase(); + var key = JobKey(jobId); + var now = _timeProvider.GetUtcNow(); + + // 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[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; + + // Build hash field updates + var updates = new List + { + new("Status", ((int)status).ToString(CultureInfo.InvariantCulture)), + new("LastUpdatedUtc", now.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)) + }; + + if (startedUtc.HasValue) + updates.Add(new("StartedUtc", startedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); + if (completedUtc.HasValue) + updates.Add(new("CompletedUtc", completedUtc.Value.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture))); + if (errorMessage is not null) + updates.Add(new("ErrorMessage", errorMessage)); + if (progress.HasValue) + updates.Add(new("Progress", progress.Value.ToString(CultureInfo.InvariantCulture))); + if (attempt.HasValue) + updates.Add(new("Attempt", attempt.Value.ToString(CultureInfo.InvariantCulture))); + + 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()); + + if (oldStatus != status) + _ = txn.SortedSetRemoveAsync(StatusSetKey(queueName, oldStatus), jobId); + _ = txn.SortedSetAddAsync(newStatusSetKey, jobId, createdScore); + + if (ttl.HasValue) + { + _ = 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) + { + var db = _redis.GetDatabase(); + var key = JobKey(jobId); + + if (!await db.KeyExistsAsync(key).ConfigureAwait(false)) + return; + + var now = _timeProvider.GetUtcNow(); + 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) + { + 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); + + // 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) + { + 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 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 + _ = db.KeyExpireAsync(bucketKey, TimeSpan.FromHours(48), ExpireWhen.HasNoExpiry); + + return task; + } + + public async Task GetCounterStatsAsync(string queueName, TimeSpan? window = null, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + var now = _timeProvider.GetUtcNow(); + 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); + + for (int i = 0; i < hours.Count; i++) + { + 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 new QueueCounterStats { Totals = totals, Buckets = buckets }; + } + + private string JobKey(string jobId) => $"{_keyPrefix}:{jobId}"; + private string CancelKey(string jobId) => $"{_keyPrefix}:{jobId}:cancel"; + private string QueueSetKey(string queueName) => $"{_keyPrefix}:queues:{queueName}"; + private string StatusSetKey(string queueName, QueueJobStatus status) => $"{_keyPrefix}:queues:{queueName}:status:{(int)status}"; + private string CounterBucketKey(string queueName, DateTimeOffset timestamp) => $"{_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 status, int skip = 0, int take = 50, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + 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 []; + + // 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 entries = await tasks[i].ConfigureAwait(false); + if (entries.Length > 0) + results.Add(ParseJobState(entries)); + } + + return results; + } + + public Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + { + var db = _redis.GetDatabase(); + return db.SortedSetLengthAsync(StatusSetKey(queueName, status)); + } + + 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")), + Attempt = int.TryParse(dict.GetValueOrDefault("Attempt"), out var a) ? a : 0, + 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..dc8e1a29 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed.Redis/RedisServiceExtensions.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace Foundatio.Mediator.Distributed.Redis; + +/// +/// Extension methods for configuring Redis-backed services on . +/// +public static class RedisBuilderExtensions +{ + /// + /// Registers a Redis-backed for tracking queue job state. + /// Requires an to be registered in DI. + /// + /// The mediator builder. + /// Optional configuration callback. + /// The mediator builder for chaining. + /// + /// + /// services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost")); + /// services.AddMediator() + /// .AddDistributedQueues() + /// .UseRedisJobState(); + /// + /// + public static IMediatorBuilder UseRedisJobState( + this IMediatorBuilder builder, + Action? configure = null) + { + var services = builder.Services; + var options = new RedisJobStateStoreOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(sp => + new RedisQueueJobStateStore( + sp.GetRequiredService(), + sp.GetService(), + sp.GetService())); + + return builder; + } +} diff --git a/src/Foundatio.Mediator.Distributed/AssemblyInfo.cs b/src/Foundatio.Mediator.Distributed/AssemblyInfo.cs new file mode 100644 index 00000000..cde18f69 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Foundatio.Mediator; + +[assembly: FoundatioModule] 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..0749e2fc --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedInfrastructureInitializer.cs @@ -0,0 +1,127 @@ +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed; + +/// +/// Hosted service that pre-creates all queues and topics in the background. +/// Workers and publishers await +/// before using infrastructure, so the app can start accepting requests immediately. +/// +internal sealed class DistributedInfrastructureInitializer( + IQueueClient? queueClient, + IPubSubClient? pubSubClient, + DistributedInfrastructureOptions options, + DistributedInfrastructureReady ready, + ILogger logger) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + if (options.QueueNames.Count == 0 && options.TopicNames.Count == 0) + { + ready.SetReady(); + return Task.CompletedTask; + } + + // Fire and forget — workers await ready.WaitAsync() before polling + _ = InitializeAsync(cancellationToken); + return Task.CompletedTask; + } + + private async Task InitializeAsync(CancellationToken cancellationToken) + { + try + { + var sw = Stopwatch.StartNew(); + using var activity = MediatorActivitySource.Instance.StartActivity("Mediator Infrastructure Setup"); + + // Warm up the transport connections with one real call per transport. + // The first AWS SDK call absorbs DNS resolution, TLS handshake, and + // endpoint discovery (~20s against cold LocalStack). Creating one queue + // and one topic first means the parallel batch below gets warm connections. + var warmUpTasks = new List(2); + if (options.QueueNames.Count > 0 && queueClient is not null) + warmUpTasks.Add(queueClient.EnsureQueuesAsync([options.QueueNames[0]], cancellationToken)); + if (options.TopicNames.Count > 0 && pubSubClient is not null) + warmUpTasks.Add(pubSubClient.EnsureTopicsAsync([options.TopicNames[0]], cancellationToken)); + await Task.WhenAll(warmUpTasks).ConfigureAwait(false); + logger.LogInformation("Transport connections warm in {ElapsedMs}ms", sw.ElapsedMilliseconds); + + // Now create the remaining queues/topics with warm connections + var tasks = new List(2); + + if (options.QueueNames.Count > 1 && queueClient is not null) + { + var remaining = options.QueueNames.Skip(1).ToList(); + logger.LogInformation("Ensuring remaining {Count} queue(s) exist: {Queues}", remaining.Count, remaining); + tasks.Add(queueClient.EnsureQueuesAsync(remaining, cancellationToken)); + } + + // Topic was already fully set up (queue + subscribe) during warm-up + // so we only need to process additional topics if there are more than one. + if (options.TopicNames.Count > 1 && pubSubClient is not null) + { + var remaining = options.TopicNames.Skip(1).ToList(); + logger.LogInformation("Ensuring remaining {Count} topic(s) exist: {Topics}", remaining.Count, remaining); + tasks.Add(pubSubClient.EnsureTopicsAsync(remaining, cancellationToken)); + } + + if (tasks.Count > 0) + await Task.WhenAll(tasks).ConfigureAwait(false); + + logger.LogInformation("Distributed infrastructure ready in {ElapsedMs}ms", sw.ElapsedMilliseconds); + ready.SetReady(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize distributed infrastructure"); + ready.SetFailed(ex); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +/// +/// Signals that distributed infrastructure (queues, topics) has been created. +/// Workers await before starting their polling loops. +/// +public sealed class DistributedInfrastructureReady +{ + private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Blocks until infrastructure is ready or throws if initialization failed. + /// + public Task WaitAsync(CancellationToken cancellationToken = default) + { + if (_tcs.Task.IsCompleted) + return _tcs.Task; + + return WaitWithCancellationAsync(cancellationToken); + } + + private async Task WaitWithCancellationAsync(CancellationToken cancellationToken) + { + var cancelTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var reg = cancellationToken.Register(() => cancelTcs.TrySetCanceled(cancellationToken)); + await Task.WhenAny(_tcs.Task, cancelTcs.Task).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await _tcs.Task.ConfigureAwait(false); // propagate any failure + } + + internal void SetReady() => _tcs.TrySetResult(); + + internal void SetFailed(Exception ex) => _tcs.TrySetException(ex); +} + +/// +/// 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/DistributedNotificationAttribute.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationAttribute.cs new file mode 100644 index 00000000..0807ea17 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationAttribute.cs @@ -0,0 +1,15 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Marks a notification type for distributed fan-out via pub/sub. +/// This is an alternative to implementing — +/// use this attribute when you cannot or prefer not to modify the type hierarchy. +/// +/// +/// +/// [DistributedNotification] +/// public record OrderCreated(string OrderId, DateTime CreatedAt); +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = true)] +public sealed class DistributedNotificationAttribute : Attribute; diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs new file mode 100644 index 00000000..af8c9c7f --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationOptions.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using System.Reflection; +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; + + /// + /// Optional prefix applied to topic and per-node queue names for app-level scoping. + /// When set, topic names become "{ResourcePrefix}-{Topic}". + /// When null or empty (default), names are used as-is. + /// + /// + /// Use this to isolate multiple applications sharing the same infrastructure + /// (e.g., "myapp" produces topic "myapp-distributed-notifications"). + /// + public string? ResourcePrefix { get; set; } + + /// + /// When true, all notification types are distributed via pub/sub, + /// regardless of whether they implement + /// or are decorated with . + /// Default is false. + /// + public bool IncludeAllNotifications { get; set; } + + /// + /// Optional predicate evaluated for notification types that are not already included + /// by , , + /// or explicit calls. Return true to distribute the type. + /// + /// + /// + /// opts.MessageFilter = type => type.Namespace?.StartsWith("MyApp.Events") == true; + /// + /// + public Func? MessageFilter { get; set; } + + /// + /// Explicitly includes a notification type for distributed fan-out. + /// Use this when the type cannot implement + /// or be decorated with . + /// + /// The notification type to distribute. + /// This options instance for chaining. + public DistributedNotificationOptions Include() + { + IncludedTypes.Add(typeof(T)); + _shouldDistributeCache.TryRemove(typeof(T), out _); + return this; + } + + /// + /// Explicitly includes a notification type for distributed fan-out. + /// + /// The notification type to distribute. + /// This options instance for chaining. + public DistributedNotificationOptions Include(Type type) + { + IncludedTypes.Add(type); + _shouldDistributeCache.TryRemove(type, out _); + return this; + } + + /// + /// Scans the assembly containing and includes all + /// public notification types (classes and structs) for distributed fan-out. + /// A type is considered a notification if it implements . + /// + /// A type whose assembly will be scanned. + /// This options instance for chaining. + 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; + } + + /// + /// Types explicitly added via or . + /// + 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) + { + return _shouldDistributeCache.GetOrAdd(messageType, static (type, self) => + { + if (self.IncludedTypes.Contains(type)) + return true; + + if (typeof(IDistributedNotification).IsAssignableFrom(type)) + return true; + + if (type.GetCustomAttribute() is not null) + return true; + + if (self.MessageFilter is not null) + return self.MessageFilter(type); + + return self.IncludeAllNotifications; + }, this); + } + + /// + /// Returns with applied when configured. + /// + internal string EffectiveTopic => + string.IsNullOrEmpty(ResourcePrefix) ? Topic : $"{ResourcePrefix}-{Topic}"; +} diff --git a/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs new file mode 100644 index 00000000..7414e11a --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedNotificationWorker.cs @@ -0,0 +1,287 @@ +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 locally published distributed notifications +/// to a remote (outbound) and re-publishes inbound bus messages +/// to the local mediator. +/// +/// +/// Outbound loop: uses mediator.SubscribeAsync<MessageContext<object>>() +/// to tap into all locally published notifications, filters to types that should be distributed +/// (via ), 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; + private readonly MessageTypeResolver? _typeResolver; + + /// + /// 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); + + /// + /// 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; + + public DistributedNotificationWorker( + IServiceScopeFactory scopeFactory, + IPubSubClient bus, + DistributedNotificationOptions options, + ILogger logger, + MessageTypeResolver? typeResolver = null, + DistributedInfrastructureReady? infraReady = null, + TimeProvider? timeProvider = null) + { + _scopeFactory = scopeFactory; + _bus = bus; + _options = options; + _jsonOptions = options.JsonSerializerOptions ?? JsonSerializerOptions.Default; + _logger = logger; + _typeResolver = typeResolver; + _infraReady = infraReady; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait for topics to be created before subscribing + if (_infraReady is not null) + { + try { await _infraReady.WaitAsync(stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + } + + _logger.LogInformation( + "Distributed notification worker starting (HostId={HostId}, Topic={Topic})", + _options.HostId, _options.EffectiveTopic); + + 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; + + // Filter to only types that should be distributed + if (!_options.ShouldDistribute(notification.GetType())) + continue; + + // 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.FullName!, + [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.EffectiveTopic, [new PubSubEntry { Body = body, Headers = 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.EffectiveTopic, 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 = _typeResolver?.TryResolve(typeName); + if (messageType is null) + { + _logger.LogWarning("Cannot resolve type '{TypeName}' from bus message — type not registered in MessageTypeResolver, 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. + 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 + { + // 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/DistributedQueueOptions.cs b/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs new file mode 100644 index 00000000..cab33247 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedQueueOptions.cs @@ -0,0 +1,66 @@ +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; } + + /// + /// Controls whether queue worker hosted services are started. When false, + /// queue middleware is still registered (so messages can be enqueued to a transport) + /// but no instances are started in this process. + /// Default is true. + /// + /// + /// Use this to run API-only nodes that enqueue work without processing it locally. + /// Worker nodes in a separate process can then dequeue and handle those messages. + /// + public bool WorkersEnabled { get; set; } = true; + + /// + /// When set, only start workers whose queue name or + /// matches an entry in this set. Both queue names (e.g., "DemoExportJob") and + /// group names (e.g., "exports") are accepted. + /// When null or empty, all queue workers are started (subject to + /// and filtering). + /// + /// + /// This is more granular than , which filters by a single group name. + /// accepts multiple values and matches against both queue names and group names. + /// When both and are set, both filters are applied. + /// + public HashSet? Queues { get; set; } + + /// + /// Optional prefix applied to all queue names for app-level scoping. + /// When set, queue names become "{ResourcePrefix}-{QueueName}". + /// When null or empty (default), queue names are used as-is. + /// + /// + /// Use this to isolate multiple applications sharing the same infrastructure + /// (e.g., "myapp" produces queues like "myapp-CreateOrder"). + /// Dead-letter queues inherit the prefix automatically. + /// + public string? ResourcePrefix { get; set; } + + /// + /// Applies to the given queue name. + /// Returns the name unchanged when no prefix is configured. + /// + 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 new file mode 100644 index 00000000..f8e198dd --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/DistributedServiceExtensions.cs @@ -0,0 +1,320 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Foundatio.Mediator.Distributed; + +/// +/// 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 mediator builder. + /// Optional configuration callback for . + /// The mediator builder for chaining. + /// + /// + /// services.AddMediator() + /// .AddDistributedQueues(); + /// + /// // Or with options: + /// services.AddMediator() + /// .AddDistributedQueues(opts => opts.Group = "order-processing"); + /// + /// + public static IMediatorBuilder AddDistributedQueues( + this IMediatorBuilder builder, + Action? configure = null) + { + var services = builder.Services; + + // Prevent double registration + if (services.Any(sd => sd.ServiceType == typeof(QueueMiddleware))) + return builder; + + var registry = services.GetHandlerRegistry() + ?? throw new InvalidOperationException( + "AddDistributedQueues requires AddMediator to be called first."); + + var options = new DistributedQueueOptions(); + 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 builder; + + // 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 and type resolver + var workerRegistry = new QueueWorkerRegistry(); + services.AddSingleton(workerRegistry); + var typeResolver = GetOrAddTypeResolver(services); + + // Track whether any handler uses progress tracking + bool anyTrackProgress = false; + + // 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) + { + 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 resource prefix for app-level scoping (e.g., "myapp-CreateOrder") + queueName = options.ApplyPrefix(queueName); + + // Register this message type in the type resolver for safe deserialization + typeResolver.Register(messageType); + + // Skip if already processed 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; + + var group = queueAttr?.Group; + + // 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(new QueueDefinition { Name = queueName }); + + var visibilityTimeout = TimeSpan.FromSeconds(queueAttr?.TimeoutSeconds ?? 30); + var retryDelay = TimeSpan.FromSeconds(queueAttr?.RetryDelaySeconds ?? 5); + + var trackProgress = queueAttr?.TrackProgress ?? false; + 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 = concurrency, + PrefetchCount = prefetchCount, + VisibilityTimeout = visibilityTimeout, + MaxAttempts = queueAttr?.MaxAttempts ?? 3, + RetryPolicy = queueAttr?.RetryPolicy ?? QueueRetryPolicy.Exponential, + RetryDelay = retryDelay, + Group = group, + AutoComplete = queueAttr?.AutoComplete ?? true, + AutoRenewTimeout = queueAttr?.AutoRenewTimeout ?? true, + TrackProgress = trackProgress + }; + + // Always register worker info for dashboard visibility across all nodes + var workerInfo = new QueueWorkerInfo + { + QueueName = queueName, + MessageTypeName = messageType.FullName ?? messageType.Name, + Concurrency = workerOptions.Concurrency, + PrefetchCount = workerOptions.PrefetchCount, + MaxAttempts = workerOptions.MaxAttempts, + VisibilityTimeout = workerOptions.VisibilityTimeout, + Group = workerOptions.Group, + RetryPolicy = workerOptions.RetryPolicy, + TrackProgress = workerOptions.TrackProgress, + Description = queueAttr?.Description + }; + workerRegistry.Register(workerInfo); + + // Determine whether this worker should start in this process. + // Workers are skipped when: + // - WorkersEnabled is false (API-only nodes that only enqueue) + // - Group filter is set and doesn't match the handler's group + // - Queues filter is set and neither the queue name nor group name matches + if (!options.WorkersEnabled) + continue; + + if (options.Group is not null && !string.Equals(options.Group, group, StringComparison.OrdinalIgnoreCase)) + continue; + + if (options.Queues is { Count: > 0 } queues + && !queues.Contains(queueName, StringComparer.OrdinalIgnoreCase) + && (group is null || !queues.Contains(group, StringComparer.OrdinalIgnoreCase))) + continue; + + // Mark that a worker is actually running on this node + workerInfo.Stats.SetWorkerRegistered(true); + + // 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(), + 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 builder; + } + + /// + /// Adds distributed notification fan-out support to Foundatio.Mediator. + /// Notifications are distributed when they implement , + /// are decorated with , are explicitly included + /// via , match + /// , or when + /// is enabled. + /// + /// The mediator builder. + /// Optional configuration callback for . + /// The mediator builder for chaining. + /// + /// + /// services.AddMediator() + /// .AddDistributedNotifications(); + /// + /// // Or with options: + /// services.AddMediator() + /// .AddDistributedNotifications(opts => + /// { + /// opts.Topic = "my-app-notifications"; + /// opts.Include<OrderCreated>(); + /// }); + /// + /// + public static IMediatorBuilder AddDistributedNotifications( + this IMediatorBuilder builder, + Action? configure = null) + { + var services = builder.Services; + + // Prevent double registration + if (services.Any(sd => sd.ServiceType == typeof(DistributedNotificationOptions))) + return builder; + + 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(new TopicDefinition { Name = options.EffectiveTopic }); + + // Register the background worker + // Build the resolved set of distributed types for the worker to filter on. + var distributedTypes = new HashSet(); + + // Register known notification types in the type resolver + var registry = services.GetHandlerRegistry(); + var typeResolver = GetOrAddTypeResolver(services); + if (registry is not null) + { + foreach (var reg in registry.Registrations) + { + if (reg.MessageType is not null && options.ShouldDistribute(reg.MessageType)) + { + typeResolver.Register(reg.MessageType); + distributedTypes.Add(reg.MessageType); + } + } + } + + // Also register explicitly included types that may not have handlers in the registry + // (e.g., types only consumed on other nodes) + if (distributedTypes.Count == 0 && options.IncludedTypes.Count == 0 + && !options.IncludeAllNotifications && options.MessageFilter is null) + { + // No distributed types discovered and no dynamic filters — skip the worker entirely + return builder; + } + + foreach (var type in options.IncludedTypes) + { + typeResolver.Register(type); + distributedTypes.Add(type); + } + + services.AddSingleton(sp => new DistributedNotificationWorker( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetService(), + sp.GetService(), + sp.GetService())); + + return builder; + } + + 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); + + var ready = new DistributedInfrastructureReady(); + services.AddSingleton(ready); + + // Register the initializer — starts infrastructure creation in the background + services.AddSingleton(sp => new DistributedInfrastructureInitializer( + sp.GetService(), + sp.GetService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + 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/Foundatio.Mediator.Distributed.csproj b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj new file mode 100644 index 00000000..24eca36e --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/Foundatio.Mediator.Distributed.csproj @@ -0,0 +1,17 @@ + + + + + + net10.0 + latest + enable + Foundatio.Mediator.Distributed + + + + + + + + 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..0e4392d2 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IPubSubClient.cs @@ -0,0 +1,47 @@ +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 +{ + /// + /// 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 messages containing body and optional headers. + /// A cancellation token. + Task PublishAsync(string topic, IReadOnlyList messages, 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; + + /// + ValueTask IAsyncDisposable.DisposeAsync() => default; +} diff --git a/src/Foundatio.Mediator.Distributed/IQueueClient.cs b/src/Foundatio.Mediator.Distributed/IQueueClient.cs new file mode 100644 index 00000000..3c99ea6d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IQueueClient.cs @@ -0,0 +1,77 @@ +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 : IAsyncDisposable +{ + /// + /// Sends one or more messages to the specified queue. + /// Implementations may use transport-native batch APIs for better throughput. + /// + Task SendAsync(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. + /// + /// + /// 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. + Task DeadLetterAsync(QueueMessage message, string reason, CancellationToken cancellationToken = default); + + /// + /// Ensures the specified queues exist, creating them if necessary. + /// Implementations may batch the operations for efficiency. + /// + Task EnsureQueuesAsync(IReadOnlyList queues, CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + /// Gets transport-level statistics for the specified queues. + /// Not all transports support all metrics; unsupported values will be zero. + /// + 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/IQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs new file mode 100644 index 00000000..d1ba6417 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/IQueueJobStateStore.cs @@ -0,0 +1,104 @@ +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); + + /// + /// Atomically updates job status and related fields without requiring a prior read. + /// Implementations should update only the supplied fields plus LastUpdatedUtc. + /// + /// 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. + /// 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); + + /// + /// 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 + /// 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 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"). + /// 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 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. + /// 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 for a given status, ordered by creation time descending. + /// + 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. + /// + Task GetJobCountByStatusAsync(string queueName, QueueJobStatus status, CancellationToken cancellationToken = default) + => Task.FromResult(0L); +} 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..1f763df4 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/InMemoryPubSubClient.cs @@ -0,0 +1,125 @@ +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(); + private readonly ConcurrentBag _activeCts = []; + + /// + public Task PublishAsync(string topic, IReadOnlyList entries, CancellationToken cancellationToken = default) + { + if (!_subscriptions.TryGetValue(topic, out var subs)) + return Task.CompletedTask; + + foreach (var entry in entries) + { + 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; + } + + /// + 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); + _activeCts.Add(cts); + _ = Task.Run(async () => + { + try + { + await foreach (var msg in channel.Reader.ReadAllAsync(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) { } + }, 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() + { + // Cancel all active subscription consumer tasks + foreach (var cts in _activeCts) + { + try { cts.Cancel(); cts.Dispose(); } catch { } + } + + foreach (var topicEntries in _subscriptions.Values) + { + foreach (var entry in topicEntries.Values) + entry.Writer.TryComplete(); + topicEntries.Clear(); + } + _subscriptions.Clear(); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return default; + } + + 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..3cf33ce9 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueClient.cs @@ -0,0 +1,202 @@ +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 async Task SendAsync(string queueName, IReadOnlyList entries, CancellationToken cancellationToken = default) + { + var channel = GetOrCreateChannel(queueName); + foreach (var entry in entries) + { + 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) + { + 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.IncrementDequeueCount(); + results.Add(ToQueueMessage(first, queueName, now)); + + // Try to read more without waiting + while (results.Count < maxCount && channel.Reader.TryRead(out var entry)) + { + entry.IncrementDequeueCount(); + 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(IReadOnlyList queueNames, CancellationToken cancellationToken = default) + { + 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; + + 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() + { + 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; } + 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/InMemoryQueueJobStateStore.cs b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs new file mode 100644 index 00000000..d37f4e5c --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/InMemoryQueueJobStateStore.cs @@ -0,0 +1,210 @@ +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> _counterBuckets = 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)) + return Task.FromResult(null); + + if (!IsExpired(entry)) + return Task.FromResult(entry.State); + + // Remove expired entry on access + _jobs.TryRemove(jobId, out _); + _cancellations.TryRemove(jobId, out _); + + return Task.FromResult(null); + } + + public 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) + { + 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 + { + Status = status, + StartedUtc = startedUtc ?? entry.State.StartedUtc, + CompletedUtc = completedUtc ?? entry.State.CompletedUtc, + ErrorMessage = errorMessage ?? entry.State.ErrorMessage, + Progress = progress ?? entry.State.Progress, + Attempt = attempt ?? entry.State.Attempt, + LastUpdatedUtc = now + }; + + _jobs[jobId] = new JobEntry(updated, expiresAt); + return Task.CompletedTask; + } + + 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) + { + 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 bucketKey = GetBucketKey(queueName, _timeProvider.GetUtcNow()); + var bucket = _counterBuckets.GetOrAdd(bucketKey, _ => new ConcurrentDictionary()); + bucket.AddOrUpdate(counterName, value, (_, existing) => existing + value); + return Task.CompletedTask; + } + + public Task GetCounterStatsAsync(string queueName, TimeSpan? window = null, CancellationToken cancellationToken = default) + { + 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(); + + 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 }); + } + + 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 results = _jobs.Values + .Where(e => !IsExpired(e, now) + && string.Equals(e.State.QueueName, queueName, StringComparison.OrdinalIgnoreCase) + && e.State.Status == 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/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 new file mode 100644 index 00000000..10628d7d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/PubSubMessage.cs @@ -0,0 +1,33 @@ +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. +/// +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..d371d15d --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueAttribute.cs @@ -0,0 +1,106 @@ +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 times the message will be attempted before dead-lettering. + /// Default is 3 (1 initial attempt + 2 retries). + /// + public int MaxAttempts { get; set; } = 3; + + /// + /// Work item timeout in seconds. + /// The timeout auto-renews on a background timer unless is disabled, + /// in which case the message is automatically abandoned if not completed within this duration. + /// Default is 30. + /// + public int TimeoutSeconds { get; set; } = 30; + + /// + /// 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. + /// 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; } + + /// + /// 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. When false, the handler must call + /// or + /// explicitly. Default is true. + /// + public bool AutoComplete { get; set; } = true; + + /// + /// When true, the worker automatically renews the message visibility timeout + /// on a background timer, preventing the message from being redelivered while + /// the handler is still processing. When false, the handler must call + /// manually for long-running work. + /// Default is true. + /// + public bool AutoRenewTimeout { get; set; } = true; + + /// + /// The retry delay strategy for failed messages. Default is . + /// + public QueueRetryPolicy RetryPolicy { get; set; } = QueueRetryPolicy.Exponential; + + /// + /// The base delay between retries in seconds. + /// For , this is the constant delay. + /// For , this is the initial delay that doubles on each retry. + /// Default is 5. + /// + public int RetryDelaySeconds { get; set; } = 5; + + /// + /// 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; } + + /// + /// A human-readable description of the queue, shown in the dashboard tooltip. + /// + public string? Description { get; set; } +} diff --git a/src/Foundatio.Mediator.Distributed/QueueClientBase.cs b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs new file mode 100644 index 00000000..2eb7937e --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueClientBase.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +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 sends to {queueName}-dead-letter then completes), +/// (default returns zeroed stats). +/// +/// +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, IReadOnlyList entries, CancellationToken cancellationToken = default); + + /// + 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). + /// + /// 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, + [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 queues, 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(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/QueueContext.cs b/src/Foundatio.Mediator.Distributed/QueueContext.cs new file mode 100644 index 00000000..246c352b --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueContext.cs @@ -0,0 +1,205 @@ +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); +/// } +/// } +/// } +/// +/// When AutoComplete is disabled, the handler is responsible for completing +/// or abandoning the message explicitly: +/// +/// [Queue(AutoComplete = false)] +/// public class ManualAckHandler +/// { +/// public async Task HandleAsync( +/// ProcessPayment message, +/// QueueContext queueContext, +/// CancellationToken ct) +/// { +/// try +/// { +/// await ChargeAsync(message, ct); +/// await queueContext.CompleteAsync(ct); +/// } +/// catch (TransientException) +/// { +/// await queueContext.AbandonAsync(TimeSpan.FromSeconds(30), 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 attempts configured for this queue. + /// After this many attempts, the message will be dead-lettered. + /// + public int MaxAttempts { 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. + /// + 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. + /// + 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. + /// + internal Func? OnRenewTimeout { get; init; } + + /// + /// Delegate invoked by to remove the message from the queue. + /// Set by the worker infrastructure. + /// + internal Func? OnComplete { get; init; } + + /// + /// Delegate invoked by to + /// return the message to the queue for redelivery. Set by the worker infrastructure. + /// + internal Func? OnAbandon { get; init; } + + /// + /// Indicates whether the handler explicitly completed the message via . + /// When true, the worker infrastructure will skip automatic completion. + /// + public bool IsCompleted { get; internal set; } + + /// + /// Indicates whether the handler explicitly abandoned the message via + /// or . + /// When true, the worker infrastructure will skip automatic abandonment. + /// + public bool IsAbandoned { get; internal set; } + + /// + /// 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; + + /// + /// Completes the message, removing it from the queue permanently. + /// Use this when AutoComplete is disabled and the handler has finished + /// processing successfully. If AutoComplete is enabled, the worker + /// infrastructure will skip its own completion when this has been called. + /// + public async Task CompleteAsync(CancellationToken cancellationToken = default) + { + if (OnComplete is not null) + await OnComplete(cancellationToken).ConfigureAwait(false); + + IsCompleted = true; + } + + /// + /// Abandons the message so it becomes immediately visible for redelivery. + /// Use this when AutoComplete is disabled and the handler cannot + /// process the message successfully. + /// + public Task AbandonAsync(CancellationToken cancellationToken = default) + => AbandonAsync(TimeSpan.Zero, cancellationToken); + + /// + /// Abandons the message so it becomes visible for redelivery after the specified delay. + /// Use this when AutoComplete is disabled and the handler wants to retry + /// the message after a backoff period. + /// + /// How long before the message becomes visible again. Use for immediate redelivery. + /// A cancellation token. + public async Task AbandonAsync(TimeSpan delay, CancellationToken cancellationToken = default) + { + if (OnAbandon is not null) + await OnAbandon(delay, cancellationToken).ConfigureAwait(false); + + IsAbandoned = true; + } +} 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/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/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..10a044a1 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueJobState.cs @@ -0,0 +1,100 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// Represents the current state of a queue job being tracked. +/// Immutable — use with { } expressions to create modified copies. +/// +public sealed record 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; init; } = QueueJobStatus.Queued; + + /// + /// Progress percentage (0–100). Updated by the handler via . + /// + public int Progress { get; init; } + + /// + /// Optional message describing what the job is currently doing. + /// + public string? ProgressMessage { get; init; } + + /// + /// When the job was created (enqueued). + /// + public DateTimeOffset CreatedUtc { get; init; } + + /// + /// When the worker started processing the job. + /// + public DateTimeOffset? StartedUtc { get; init; } + + /// + /// When the job reached a terminal state (Completed, Failed, or Cancelled). + /// + public DateTimeOffset? CompletedUtc { get; init; } + + /// + /// The current processing attempt (1 = first attempt, 2+ = retry). + /// Set when the worker picks up the message from the queue. + /// + public int Attempt { get; init; } + + /// + /// Error message when the job has failed. + /// + public string? ErrorMessage { get; init; } + + /// + /// The last time this state was updated. + /// + public DateTimeOffset LastUpdatedUtc { get; init; } +} + +/// +/// 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..4d759bb5 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueMiddleware.cs @@ -0,0 +1,158 @@ +using System.Collections.Concurrent; +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 HandlerRegistry _registry; + private readonly JsonSerializerOptions _jsonOptions; + private readonly TimeProvider _timeProvider; + private readonly string? _resourcePrefix; + private readonly ConcurrentDictionary _metadataCache = new(StringComparer.Ordinal); + + 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; + _resourcePrefix = options?.ResourcePrefix; + } + + public async ValueTask ExecuteAsync( + object message, + HandlerExecutionDelegate next, + HandlerExecutionInfo handlerInfo, + CallContext? callContext, + CancellationToken cancellationToken) + { + // 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 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.FullName!, + [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; + if (metadata.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 = metadata.QueueName, + MessageType = messageType.FullName ?? messageType.Name, + Status = QueueJobStatus.Queued, + CreatedUtc = now, + LastUpdatedUtc = now + }; + + await _stateStore.SetJobStateAsync(jobState, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + var entry = new QueueEntry + { + Body = body, + Headers = headers + }; + + await _client.SendAsync(metadata.QueueName, [entry], cancellationToken).ConfigureAwait(false); + + if (jobId is not null) + return Result.Accepted("Message queued", jobId); + + return Result.Accepted("Message queued"); + } + + private QueueHandlerMetadata GetMetadata(string descriptorId, Type messageType) + { + return _metadataCache.GetOrAdd(descriptorId, static (id, state) => + { + var (registry, fallbackName, prefix) = state; + string queueName; + bool trackProgress; + string? returnTypeName = null; + + if (registry.TryGetHandlerByDescriptorId(id, out var registration) && registration is not null) + { + var queueAttr = registration.GetPreferredAttribute()?.Attribute as QueueAttribute; + queueName = !string.IsNullOrWhiteSpace(queueAttr?.QueueName) ? queueAttr!.QueueName! : fallbackName; + trackProgress = queueAttr?.TrackProgress ?? false; + returnTypeName = registration.ReturnTypeName; + } + else + { + queueName = fallbackName; + trackProgress = false; + } + + // Apply resource prefix for app-level scoping + if (!string.IsNullOrEmpty(prefix)) + queueName = $"{prefix}-{queueName}"; + + return new QueueHandlerMetadata(queueName, trackProgress, returnTypeName); + }, (_registry, messageType.Name, _resourcePrefix)); + } + + private sealed record QueueHandlerMetadata(string QueueName, bool TrackProgress, string? ReturnTypeName); +} 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/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..757ba6ad --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorker.cs @@ -0,0 +1,553 @@ +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 DistributedInfrastructureReady? _infraReady; + private readonly JsonSerializerOptions _jsonOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private static readonly TimeSpan s_defaultStateExpiry = TimeSpan.FromHours(24); + + public QueueWorker( + IQueueClient client, + IServiceScopeFactory scopeFactory, + QueueWorkerOptions options, + DistributedQueueOptions? distributedOptions, + ILogger logger, + QueueWorkerInfo? workerInfo = null, + IQueueJobStateStore? stateStore = null, + DistributedInfrastructureReady? infraReady = null, + TimeProvider? timeProvider = null) + { + _client = client; + _scopeFactory = scopeFactory; + _options = options; + _workerInfo = workerInfo; + _stateStore = stateStore; + _infraReady = infraReady; + _jsonOptions = distributedOptions?.JsonSerializerOptions ?? JsonSerializerOptions.Default; + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait for queues/topics to be created before polling + if (_infraReady is not null) + { + try { await _infraReady.WaitAsync(stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + } + + _workerInfo?.Stats.SetRunning(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(); + + // 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); + } + + _logger.LogInformation("Queue worker stopped for {QueueName}", _options.QueueName); + } + finally + { + _workerInfo?.Stats.SetRunning(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 max attempts, move it to the DLQ + if (_options.MaxAttempts >= 0 && message.DequeueCount > _options.MaxAttempts) + { + _logger.LogWarning( + "Message {MessageId} on {QueueName} exceeded max attempts ({DequeueCount}/{MaxAttempts}), dead-lettering", + message.Id, _options.QueueName, message.DequeueCount, _options.MaxAttempts); + + activity?.SetTag("messaging.dead_letter", true); + activity?.SetTag("messaging.dead_letter.reason", "MaxAttemptsExceeded"); + + _workerInfo?.Stats.IncrementDeadLettered(); + + if (_stateStore is not null) + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken)).ConfigureAwait(false); + + await DeadLetterAsync(message, $"Exceeded max attempts ({_options.MaxAttempts})").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); + + // When auto-renew is disabled, enforce the visibility timeout as a hard deadline. + // When enabled, the background timer keeps extending the lock so we don't cancel. + if (!_options.AutoRenewTimeout) + 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); + + // Start auto-renew timer: renews at 2/3 of the visibility timeout to maintain exclusive access + Task? autoRenewTask = null; + if (_options.AutoRenewTimeout) + autoRenewTask = AutoRenewTimeoutAsync(message, timeoutCts.Token); + + var linkedToken = timeoutCts.Token; + + QueueContext? queueContext = null; + + try + { + // Update state to Processing + if (trackProgress && jobId is not null) + 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); + if (typedMessage is null) + { + _logger.LogWarning("Failed to deserialize message {MessageId} from {QueueName} as {MessageType}", + message.Id, _options.QueueName, _options.MessageType.Name); + + if (jobId is not null) + 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; + } + + // Build QueueContext with delegates wired to IQueueClient + queueContext = new QueueContext + { + QueueName = _options.QueueName, + MessageType = _options.MessageType, + DequeueCount = message.DequeueCount, + MaxAttempts = _options.MaxAttempts, + 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, + OnComplete = ct => _client.CompleteAsync(message, ct), + OnAbandon = (delay, ct) => _client.AbandonAsync(message, delay, ct) + }; + + 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) + // Pass typeof(object) as responseType so UntypedHandleAsync returns the actual result + // instead of null (which it does when responseType is null for fire-and-forget scenarios). + var handlerResult = await _options.Registration.HandleAsync(mediator, typedMessage, callContext, linkedToken, typeof(object), skipAuthorization: true).ConfigureAwait(false); + + // Inspect the handler result to drive message lifecycle. + // If the handler returns an IResult, use its status to determine completion vs retry vs dead-letter. + // Void/Task handlers return null, which is treated as success. + if (handlerResult is IResult result && !result.IsSuccess && !queueContext.IsCompleted && !queueContext.IsAbandoned) + { + var errorMessage = !string.IsNullOrEmpty(result.Message) ? result.Message : $"Handler returned {result.Status}"; + + if (IsRetryableStatus(result.Status)) + { + // Retryable failure — abandon for retry + _logger.LogWarning("Handler returned retryable status {Status} for message {MessageId} on {QueueName}: {Message}", + result.Status, message.Id, _options.QueueName, errorMessage); + + if (jobId is not null) + 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?.Stats.IncrementFailed(); + + if (_stateStore is not null) + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken)).ConfigureAwait(false); + + return; + } + else + { + // Non-retryable failure — dead-letter immediately (e.g. NotFound, Invalid, Unauthorized) + _logger.LogWarning("Handler returned non-retryable status {Status} for message {MessageId} on {QueueName}: {Message}", + result.Status, message.Id, _options.QueueName, errorMessage); + + if (jobId is not null) + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: errorMessage, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); + + _workerInfo?.Stats.IncrementDeadLettered(); + + if (_stateStore is not null) + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "dead_lettered", 1, stoppingToken)).ConfigureAwait(false); + + await DeadLetterAsync(message, errorMessage).ConfigureAwait(false); + return; + } + } + + if (_options.AutoComplete && queueContext is { IsCompleted: false, IsAbandoned: false }) + await _client.CompleteAsync(message, stoppingToken).ConfigureAwait(false); + + _workerInfo?.Stats.IncrementProcessed(); + + if (_stateStore is not null) + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "processed", 1, stoppingToken)).ConfigureAwait(false); + + // Update state to Completed + if (jobId is not null) + 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 is not { IsCompleted: true } and not { IsAbandoned: true }) + 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 by user (job {JobId})", message.Id, _options.QueueName, jobId); + 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 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 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?.Stats.IncrementFailed(); + + 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 is { IsCompleted: false, IsAbandoned: false }) + await AbandonAsync(message, stoppingToken).ConfigureAwait(false); + + _workerInfo?.Stats.IncrementFailed(); + + if (_stateStore is not null) + await TryUpdateStateAsync(() => _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.MaxAttempts); + + if (jobId is not null) + await TryUpdateStateAsync(() => _stateStore!.UpdateJobStatusAsync(jobId, QueueJobStatus.Failed, completedUtc: _timeProvider.GetUtcNow(), errorMessage: ex.Message, expiry: s_defaultStateExpiry, cancellationToken: stoppingToken)).ConfigureAwait(false); + + if (_options.AutoComplete && queueContext is { IsCompleted: false, IsAbandoned: false }) + await AbandonAsync(message, stoppingToken).ConfigureAwait(false); + + _workerInfo?.Stats.IncrementFailed(); + + if (_stateStore is not null) + await TryUpdateStateAsync(() => _stateStore.IncrementCounterAsync(_options.QueueName, "failed", 1, stoppingToken)).ConfigureAwait(false); + } + finally + { + // Stop the auto-renew and cancellation polling tasks + await timeoutCts.CancelAsync().ConfigureAwait(false); + + if (autoRenewTask is not null) + { + try { await autoRenewTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + + if (cancellationPollTask is not null) + { + 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 AutoRenewTimeoutAsync(QueueMessage message, CancellationToken cancellationToken) + { + // Renew at 2/3 of the visibility timeout to maintain exclusive access with margin + var renewInterval = _options.VisibilityTimeout * (2.0 / 3.0); + if (renewInterval <= TimeSpan.Zero) + return; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(renewInterval, _timeProvider, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("Auto-renewing timeout for message {MessageId} on {QueueName} by {Timeout}", + message.Id, _options.QueueName, _options.VisibilityTimeout); + + await _client.RenewTimeoutAsync(message, _options.VisibilityTimeout, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Expected when message processing completes or host stops + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to auto-renew timeout for message {MessageId} on {QueueName}; " + + "message may become visible to other consumers if processing takes longer than {Timeout}", + message.Id, _options.QueueName, _options.VisibilityTimeout); + } + } + + private async Task UpdateJobProgressAsync(string jobId, int percent, string? message, CancellationToken ct) + { + if (_stateStore is null) return; + + // 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 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 = QueueRetryDelay.Compute(_options.RetryPolicy, _options.RetryDelay, 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); + } + + 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); + } + } + + /// + /// Determines whether a failed represents a transient condition + /// that may succeed on retry. Non-retryable statuses are routed to the dead-letter queue + /// immediately because the problem is with the message content, not infrastructure. + /// + private static bool IsRetryableStatus(ResultStatus status) => status switch + { + // Transient / infrastructure errors — worth retrying + ResultStatus.Error => true, + ResultStatus.Unavailable => true, + + // Permanent / content errors — retrying won't help + ResultStatus.CriticalError => false, + ResultStatus.NotFound => false, + ResultStatus.Invalid => false, + ResultStatus.BadRequest => false, + ResultStatus.Unauthorized => false, + ResultStatus.Forbidden => false, + ResultStatus.Conflict => false, + + // Success statuses should never reach here, but treat them as non-retryable + _ => false + }; +} diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs new file mode 100644 index 00000000..55d2182e --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerInfo.cs @@ -0,0 +1,64 @@ +namespace Foundatio.Mediator.Distributed; + +/// +/// 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 +{ + /// + /// 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 number of attempts before dead-lettering. + /// + public int MaxAttempts { 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; } + + /// + /// A human-readable description of the queue. + /// + public string? Description { get; init; } + + /// + /// Runtime statistics for this worker. Updated atomically by the worker during message processing. + /// + public QueueWorkerStats Stats { get; } = new(); +} diff --git a/src/Foundatio.Mediator.Distributed/QueueWorkerOptions.cs b/src/Foundatio.Mediator.Distributed/QueueWorkerOptions.cs new file mode 100644 index 00000000..f964acc8 --- /dev/null +++ b/src/Foundatio.Mediator.Distributed/QueueWorkerOptions.cs @@ -0,0 +1,90 @@ +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.FromSeconds(30); + + /// + /// Maximum number of times the message will be attempted before dead-lettering. + /// Default is 3 (1 initial attempt + 2 retries). + /// + public int MaxAttempts { get; init; } = 3; + + /// + /// 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; + + /// + /// When true, the worker automatically renews the message visibility timeout + /// on a background timer. Default is true. + /// + public bool AutoRenewTimeout { 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.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/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 new file mode 100644 index 00000000..022a026c --- /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..2cad3d2e --- /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", "3.8.1") + .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/SqsPubSubClientTests.cs b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.cs new file mode 100644 index 00000000..45617fb7 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Aws.Tests/SqsPubSubClientTests.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 SqsPubSubClientTests(LocalStackFixture fixture, ITestOutputHelper output) + : TestWithLoggingBase(output), IClassFixture +{ + private SqsPubSubClient 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 SqsPubSubClientOptions + { + AutoCreate = true, + WaitTimeSeconds = 1, + CleanupOnDispose = true + }; + + var notificationOptions = new DistributedNotificationOptions + { + HostId = hostId ?? Guid.NewGuid().ToString("N"), + Topic = $"test-topic-{Guid.NewGuid():N}" + }; + + return new SqsPubSubClient( + snsClient, + sqsClient, + options, + notificationOptions, + Log.CreateLogger()); + } + + [Fact] + public async Task PublishAsync_WithNoSubscribers_DoesNotThrow() + { + await using var client = CreateClient(); + + await client.PublishAsync("no-sub-topic", [new PubSubEntry { Body = "hello"u8.ToArray() }], TestCancellationToken); + } + + [Fact] + public async Task SubscribeAsync_ReceivesPublishedMessage() + { + await using var client = CreateClient(); + var topic = $"test-{Guid.NewGuid():N}"; + + PubSubMessage? received = null; + using 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, [new PubSubEntry { Body = "hello"u8.ToArray(), Headers = 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(); + using 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, [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}"); + + 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; + using 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, [new PubSubEntry { Body = "test"u8.ToArray(), Headers = 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; + using 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, [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 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; + using 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, [new PubSubEntry { Body = "no-headers"u8.ToArray() }], 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..f5b0ec97 --- /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 SendAsync_Batch_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.SendAsync(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 SendAsync_Batch_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.SendAsync(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 5 second delay + await client.AbandonAsync(first[0], TimeSpan.FromSeconds(5), TestCancellationToken); + + // Should NOT be visible immediately (visibility delay is 2s, long poll is 1s) + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var immediate = await client.ReceiveAsync(queueName, 1, cts.Token); + Assert.Empty(immediate); + } +} 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..1ef110e1 --- /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..7c97f312 --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Redis.Tests/RedisQueueJobStateStoreTests.cs @@ -0,0 +1,441 @@ +#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); + + 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); + 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()); + } + + // ── GetJobsByStatus ───────────────────────────────────────────────────── + + [Fact] + 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.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); + Assert.Equal(2, jobsA.Count); + + var jobsB = await store.GetJobsByStatusAsync("QueueB", QueueJobStatus.Queued, cancellationToken: CT); + Assert.Single(jobsB); + } + + [Fact] + public async Task GetJobsByStatus_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.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); + Assert.Equal("job-1", jobs[2].JobId); + } + + [Fact] + public async Task GetJobsByStatus_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.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 0, take: 2, cancellationToken: CT); + Assert.Equal(2, page1.Count); + + var page2 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 2, take: 2, cancellationToken: CT); + Assert.Equal(2, page2.Count); + + var page3 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, 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 GetJobsByStatus_EmptyQueue_ReturnsEmpty() + { + var store = CreateStore(); + var jobs = await store.GetJobsByStatusAsync("EmptyQueue", QueueJobStatus.Queued, 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 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 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] + 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_RemovesFromStatusListing() + { + 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.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, 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(); + _ = 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 stats = await store.GetCounterStatsAsync("TestQueue", TimeSpan.FromHours(1), CT); + Assert.Equal(2, stats.Totals["processed"]); + Assert.Equal(1, stats.Totals["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 stats = await store.GetCounterStatsAsync("TestQueue", TimeSpan.FromHours(1), CT); + Assert.Equal(15, stats.Totals["processed"]); + } + + [Fact] + public async Task GetCounterStats_EmptyQueue_ReturnsEmptyTotals() + { + var store = CreateStore(); + 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] + public async Task Counters_IsolatedPerQueue() + { + var store = CreateStore(); + + await store.IncrementCounterAsync("Queue1", "processed", 3, CT); + await store.IncrementCounterAsync("Queue2", "processed", 7, 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); + + // 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/ComputeRetryDelayTests.cs b/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs new file mode 100644 index 00000000..1d79db7b --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/ComputeRetryDelayTests.cs @@ -0,0 +1,91 @@ +using Foundatio.Mediator.Distributed; + +namespace Foundatio.Mediator.Distributed.Tests; + +public class ComputeRetryDelayTests +{ + [Fact] + public void None_ReturnsZero() + { + 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() + { + Assert.Equal(TimeSpan.Zero, QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, TimeSpan.Zero, 3)); + } + + [Fact] + public void Fixed_ReturnsSameDelayWithinJitterBounds() + { + var baseDelay = TimeSpan.FromSeconds(10); + + for (int attempt = 1; attempt <= 5; attempt++) + { + var delay = QueueRetryDelay.Compute(QueueRetryPolicy.Fixed, baseDelay, 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); + + // dequeueCount=1 → retryNumber=0 → 5s * 2^0 = 5s + 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 = QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, baseDelay, 2); + Assert.InRange(delay2.TotalSeconds, 9.0, 11.0); + + // dequeueCount=3 → retryNumber=2 → 5s * 2^2 = 20s + 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 = QueueRetryDelay.Compute(QueueRetryPolicy.Exponential, baseDelay, 4); + Assert.InRange(delay4.TotalSeconds, 36.0, 44.0); + } + + [Fact] + public void Exponential_CapsAt15Minutes() + { + var baseDelay = TimeSpan.FromSeconds(5); + + // dequeueCount=20 → retryNumber=19 → 5s * 2^19 = 2,621,440s (way over 15min) + 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) + Assert.InRange(delay.TotalMinutes, 13.5, 15.0); + } + + [Fact] + public void JitterIsProportional() + { + var baseDelay = TimeSpan.FromSeconds(10); + + // Run many iterations to verify jitter stays within ±10% + var delays = Enumerable.Range(0, 100) + .Select(_ => QueueRetryDelay.Compute(QueueRetryPolicy.Fixed, baseDelay, 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..903e13cf --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/DistributedNotificationIntegrationTests.cs @@ -0,0 +1,797 @@ +#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; + +// Messages for attribute-based and options-based distribution +[DistributedNotification] +public record AttributeDistributedEvent(string Value); + +public record ExplicitIncludeEvent(string Value); + +public record FilterMatchEvent(string Value); + +public record PlainNotificationEvent(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); +} + +public class AttributeDistributedEventHandler(HandlerSignal signal) +{ + public void Handle(AttributeDistributedEvent message) => signal.Record(message.Value); +} + +public class ExplicitIncludeEventHandler(HandlerSignal signal) +{ + public void Handle(ExplicitIncludeEvent message) => signal.Record(message.Value); +} + +public class FilterMatchEventHandler(HandlerSignal signal) +{ + public void Handle(FilterMatchEvent message) => signal.Record(message.Value); +} + +public class PlainNotificationEventHandler(HandlerSignal signal) +{ + public void Handle(PlainNotificationEvent 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()) + .AddDistributedNotifications(); + + 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()) + .AddDistributedNotifications(opts => opts.HostId = "node-a"); + + // ── Node B ── + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()) + .AddDistributedNotifications(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()) + .AddDistributedNotifications(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()) + .AddDistributedNotifications(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()) + .AddDistributedNotifications(); + + 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()) + .AddDistributedNotifications(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()) + .AddDistributedNotifications(opts => opts.HostId = "node-a"); + + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()) + .AddDistributedNotifications(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(); + } + } +} + +// ── Attribute-based and options-based distribution tests ────────────── +public class DistributedNotificationFilteringTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + /// + /// [DistributedNotification] attribute: messages decorated with the attribute + /// are distributed across nodes without implementing IDistributedNotification. + /// + [Fact] + public async Task PublishAsync_AttributeDecorated_FansOut() + { + 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()) + .AddDistributedNotifications(opts => opts.HostId = "node-a"); + + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()) + .AddDistributedNotifications(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 AttributeDistributedEvent("attr-test"), cts.Token); + + await signalA.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalA.Values); + Assert.Equal("attr-test", signalA.Values[0]); + + await signalB.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalB.Values); + Assert.Equal("attr-test", signalB.Values[0]); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// Explicit Include<T>(): messages registered via options are distributed. + /// + [Fact] + public async Task PublishAsync_ExplicitInclude_FansOut() + { + 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()) + .AddDistributedNotifications(opts => + { + opts.HostId = "node-a"; + opts.Include(); + }); + + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()) + .AddDistributedNotifications(opts => + { + opts.HostId = "node-b"; + opts.Include(); + }); + + 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 ExplicitIncludeEvent("explicit-test"), cts.Token); + + await signalA.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalA.Values); + Assert.Equal("explicit-test", signalA.Values[0]); + + await signalB.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalB.Values); + Assert.Equal("explicit-test", signalB.Values[0]); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// MessageFilter predicate: types matching the predicate are distributed. + /// + [Fact] + public async Task PublishAsync_MessageFilter_FansOut() + { + 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()) + .AddDistributedNotifications(opts => + { + opts.HostId = "node-a"; + opts.MessageFilter = type => type == typeof(FilterMatchEvent); + }); + + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()) + .AddDistributedNotifications(opts => + { + opts.HostId = "node-b"; + opts.MessageFilter = type => type == typeof(FilterMatchEvent); + }); + + 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 FilterMatchEvent("filter-test"), cts.Token); + + await signalA.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalA.Values); + Assert.Equal("filter-test", signalA.Values[0]); + + await signalB.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalB.Values); + Assert.Equal("filter-test", signalB.Values[0]); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// IncludeAllNotifications: when enabled, all notification types are distributed. + /// + [Fact] + public async Task PublishAsync_IncludeAllNotifications_FansOut() + { + 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()) + .AddDistributedNotifications(opts => + { + opts.HostId = "node-a"; + opts.IncludeAllNotifications = true; + }); + + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()) + .AddDistributedNotifications(opts => + { + opts.HostId = "node-b"; + opts.IncludeAllNotifications = true; + }); + + 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 PlainNotificationEvent("all-test"), cts.Token); + + await signalA.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalA.Values); + Assert.Equal("all-test", signalA.Values[0]); + + await signalB.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalB.Values); + Assert.Equal("all-test", signalB.Values[0]); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// ShouldDistribute unit test: verifies the evaluation order and short-circuiting. + /// + [Fact] + public void ShouldDistribute_EvaluationOrder() + { + var options = new DistributedNotificationOptions(); + + // IDistributedNotification — always true by default + Assert.True(options.ShouldDistribute(typeof(TestDistributedEvent))); + + // [DistributedNotification] attribute — true + Assert.True(options.ShouldDistribute(typeof(AttributeDistributedEvent))); + + // Plain notification — false by default + Assert.False(options.ShouldDistribute(typeof(PlainNotificationEvent))); + + // Explicit include + options.Include(); + Assert.True(options.ShouldDistribute(typeof(PlainNotificationEvent))); + + // MessageFilter + var options2 = new DistributedNotificationOptions + { + MessageFilter = type => type == typeof(FilterMatchEvent) + }; + Assert.True(options2.ShouldDistribute(typeof(FilterMatchEvent))); + Assert.False(options2.ShouldDistribute(typeof(PlainNotificationEvent))); + + // IncludeAllNotifications + var options3 = new DistributedNotificationOptions { IncludeAllNotifications = true }; + Assert.True(options3.ShouldDistribute(typeof(PlainNotificationEvent))); + Assert.True(options3.ShouldDistribute(typeof(FilterMatchEvent))); + } + + /// + /// Backward compatibility: IDistributedNotification still works without any options changes. + /// + [Fact] + public async Task PublishAsync_InterfaceBased_StillWorks() + { + 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()) + .AddDistributedNotifications(opts => opts.HostId = "node-a"); + + var servicesB = new ServiceCollection(); + servicesB.AddLogging(); + servicesB.AddSingleton(signalB); + servicesB.AddSingleton(sharedBus); + servicesB.AddMediator(b => b.AddAssembly()) + .AddDistributedNotifications(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("compat"), cts.Token); + + await signalA.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalA.Values); + Assert.Equal("compat", signalA.Values[0]); + + await signalB.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signalB.Values); + Assert.Equal("compat", signalB.Values[0]); + } + finally + { + foreach (var svc in hostedA.Concat(hostedB)) + await svc.StopAsync(CancellationToken.None); + sharedBus.Dispose(); + } + } + + /// + /// Non-matching types are NOT sent to the bus even when MessageFilter is set. + /// + [Fact] + public async Task PublishAsync_FilterMismatch_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()) + .AddDistributedNotifications(opts => + { + opts.MessageFilter = type => type == typeof(FilterMatchEvent); + }); + + 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 PlainNotificationEvent("should-not-distribute"), cts.Token); + + // Local handler fires + await signal.WaitAsync(timeout: TimeSpan.FromSeconds(5)); + Assert.Single(signal.Values); + + // Wait for any potential bus activity + await Task.Delay(500, cts.Token); + + // PlainNotificationEvent doesn't match the filter — no bus publish + Assert.Equal(0, busPublishCount); + } + finally + { + foreach (var svc in hostedServices) + 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, IReadOnlyList messages, CancellationToken cancellationToken = default) + { + onPublish(); + await inner.PublishAsync(topic, messages, 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..1bcd7dc6 --- /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..33e01e3e --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryPubSubClientTests.cs @@ -0,0 +1,157 @@ +#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", [new PubSubEntry { Body = "hello"u8.ToArray() }], TestCancellationToken); + } + + [Fact] + public async Task SubscribeAsync_ReceivesPublishedMessage() + { + using var bus = new InMemoryPubSubClient(); + + PubSubMessage? received = null; + using 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", [new PubSubEntry { Body = "hello"u8.ToArray(), Headers = 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; + using 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", [new PubSubEntry { Body = "data"u8.ToArray() }], 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; + using 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", [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 + 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; + using 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", [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 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; + using 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", [new PubSubEntry + { + Body = "test"u8.ToArray(), + Headers = 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..ba8d0e5c --- /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 SendAsync_Batch_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.SendAsync(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..3b23160f --- /dev/null +++ b/tests/Foundatio.Mediator.Distributed.Tests/InMemoryQueueJobStateStoreTests.cs @@ -0,0 +1,222 @@ +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); + + 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); + Assert.Equal(QueueJobStatus.Processing, retrieved.Status); + Assert.Equal(50, retrieved.Progress); + } + + [Fact] + 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.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, cancellationToken: CT); + Assert.Equal(2, jobsA.Count); + + var jobsB = await store.GetJobsByStatusAsync("QueueB", QueueJobStatus.Queued, cancellationToken: CT); + Assert.Single(jobsB); + } + + [Fact] + public async Task GetJobsByStatus_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.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 GetJobsByStatus_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.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 0, take: 2, cancellationToken: CT); + Assert.Equal(2, page1.Count); + + var page2 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, skip: 2, take: 2, cancellationToken: CT); + Assert.Equal(2, page2.Count); + + var page3 = await store.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, 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_ExcludedFromStatusListing() + { + 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.GetJobsByStatusAsync("QueueA", QueueJobStatus.Queued, 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..c6580fa2 --- /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); + } + + // ── Send Batch ───────────────────────────────────────────────────────── + + [Fact] + public async Task SendAsync_Batch_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.SendAsync(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..7da7384b --- /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()) + .AddDistributedQueues(); + + 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()) + .AddDistributedQueues(); + + 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()) + .AddDistributedQueues(); + + 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()) + .AddDistributedQueues(); + + 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(MaxAttempts = 3, 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(MaxAttempts = 3, 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 MaxAttempts=1 (no retries, immediate dead-letter) ───── + +public record NoRetryMessage(string Value); + +[Queue(MaxAttempts = 1, 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_AfterMaxAttempts() + { + 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()) + .AddDistributedQueues(); + + 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. MaxAttempts=3 means 3 attempts + // 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 attempts", 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()) + .AddDistributedQueues(); + + 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 MaxAttemptsOne_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()) + .AddDistributedQueues(); + + 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); + + // MaxAttempts=1 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 attempts", 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..8bad3ebc --- /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()) + .AddDistributedQueues(); + + 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()) + .AddDistributedQueues(); + + 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.GetJobsByStatusAsync("TrackedCommand", QueueJobStatus.Completed, 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()) + .AddDistributedQueues(); + + 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.GetJobsByStatusAsync("TrackedLongRunningCommand", QueueJobStatus.Completed, 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()) + .AddDistributedQueues(); + + 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.GetJobsByStatusAsync("TrackedCancellableCommand", QueueJobStatus.Processing, 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()) + .AddDistributedQueues(); + + 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()) + .AddDistributedQueues(); + + 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()) + .AddDistributedQueues(); + + 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.Stats.MessagesProcessed); + Assert.Equal(0, worker.Stats.MessagesFailed); + Assert.True(worker.Stats.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()) + .AddDistributedQueues(); + + 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.EndpointGeneration.verified.txt b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt index b03e478b..c1349e3e 100644 --- a/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt +++ b/tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt @@ -416,4 +416,4 @@ public static class WidgetHandler_GetWidget_Handler } ] -} \ No newline at end of file +} 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 @@ - +