diff --git a/docs/examples/enterprise-messaging-workflows.md b/docs/examples/enterprise-messaging-workflows.md index 7760de0..b76b433 100644 --- a/docs/examples/enterprise-messaging-workflows.md +++ b/docs/examples/enterprise-messaging-workflows.md @@ -21,8 +21,8 @@ Example source: | Saga/process manager | `SagaExample.cs` | Typed message transitions over explicit saga state and completion rules. | | Mailbox | `MailboxExample.cs` | Serialized async inbox processing with explicit lifecycle and error behavior. | | Source-generated mailbox | `MailboxExample.cs` | Attribute-driven serialized inbox factories with bounded backpressure and error policy. | -| Idempotent receiver | `ReliabilityExample.cs` | Duplicate detection around at-least-once message delivery. | -| Inbox/outbox | `ReliabilityExample.cs` | Explicit handoff records for durable integration boundaries owned by the application. | +| Idempotent receiver | `ReliabilityExample.cs` | Duplicate detection around at-least-once message delivery with fluent and generated factories. | +| Inbox/outbox | `ReliabilityExample.cs` | Explicit handoff records for durable integration boundaries owned by the application, including a generated reliability pipeline. | | Source-generated dispatcher | `DispatcherExample.cs` | Compile-time mediator commands, notifications, streams, and paging. | | Source-generated content router | `ContentRouterGeneratorExample.cs` | Attribute-driven content routing without runtime scanning. | | Source-generated recipient list | `RecipientListGeneratorExample.cs` | Attribute-driven fan-out without runtime scanning. | @@ -81,7 +81,7 @@ The example tests use behavior-oriented assertions: - Routing-slip tests assert step order and header progress. - Saga tests assert transition behavior and completion state. - Mailbox tests assert serialized processing and lifecycle semantics. -- Reliability tests assert duplicate suppression and outbox record creation. +- Reliability tests assert duplicate suppression, outbox record creation, generated pipeline parity, and DI importability. - Generator tests assert that generated factories compile and behave like the equivalent runtime builders. - Resilient checkout tests assert rollback, fallback route selection, manual review, and side-effect boundaries. - Mailbox collaboration tests assert service handoff, compensation, correlation propagation, and final notification outcomes. diff --git a/docs/examples/generated-reliability-pipeline.md b/docs/examples/generated-reliability-pipeline.md new file mode 100644 index 0000000..0a12ff8 --- /dev/null +++ b/docs/examples/generated-reliability-pipeline.md @@ -0,0 +1,42 @@ +# Generated Reliability Pipeline + +The generated reliability pipeline example shows the fluent reliability primitives and the source-generated path side by side. Both paths process the same duplicate `AcceptOrder` command and dispatch exactly one `ReliabilityOrderAccepted` outbox message. + +## Integration Shape + +Register the example with the standard .NET container: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; + +var services = new ServiceCollection() + .AddGeneratedReliabilityPipelineExample(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var example = provider.GetRequiredService(); +var dispatched = await example.Runner.RunGeneratedAsync(); +``` + +The registered `PatternKitExampleServiceDescriptor` advertises messaging, source generation, and dependency-injection support so host applications can audit what they import. + +## Generated Contract + +`GeneratedReliabilityOrderPipeline` uses `[GenerateReliabilityPipeline]` to emit: + +- `CreateOrderReceiver(IIdempotencyStore)` for the idempotent receiver. +- `CreateInbox(IIdempotencyStore)` for the inbox boundary. +- `CreateOutbox()` for the outbox record store. + +The generated receiver is configured with `DuplicatePolicy = "ReplayCompleted"`, so a duplicate message with the same idempotency key replays the completed result instead of invoking the handler again. + +## Production Notes + +`InMemoryIdempotencyStore` and `InMemoryOutbox` are deterministic for tests, demos, and single-process tools. Production applications should implement `IIdempotencyStore` and outbox persistence over durable storage, usually in the same transaction as the business state change. + +## Source + +- `src/PatternKit.Examples/Messaging/ReliabilityExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs` +- `src/PatternKit.Generators/Messaging/ReliabilityPipelineGenerator.cs` +- `test/PatternKit.Generators.Tests/ReliabilityPipelineGeneratorTests.cs` diff --git a/docs/examples/index.md b/docs/examples/index.md index 685677f..81e24fc 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -78,6 +78,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Generated Mailbox** Shows fluent and source-generated serialized inboxes side by side, with an importable `IServiceCollection` extension. See [Generated Mailbox](generated-mailbox.md). +* **Generated Reliability Pipeline** + Shows fluent and source-generated idempotent receiver, inbox, and outbox composition side by side, with an importable `IServiceCollection` extension. See [Generated Reliability Pipeline](generated-reliability-pipeline.md). + * **Resilient Checkout and Collaborating Mailboxes** Application-shaped messaging demos: checkout route selection, routing-slip execution, command compensation, fallback routes, and service mailboxes collaborating over correlated messages. See [Resilient Checkout and Collaborating Mailboxes](resilient-checkout-and-mailboxes.md). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index f3cd41d..25ce9ef 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -70,6 +70,9 @@ - name: Generated Mailbox href: generated-mailbox.md +- name: Generated Reliability Pipeline + href: generated-reliability-pipeline.md + - name: Resilient Checkout and Collaborating Mailboxes href: resilient-checkout-and-mailboxes.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 5c480b7..82dae97 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -71,6 +71,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Routing Slip**](messaging.md#generated-routing-slip) | Ordered message itinerary factories | `[GenerateRoutingSlip]` | | [**Saga**](messaging.md#generated-saga) | Typed process-manager transition factories | `[GenerateSaga]` | | [**Mailbox**](messaging.md#generated-mailbox) | Serialized in-process inbox factories | `[GenerateMailbox]` | +| [**Reliability Pipeline**](messaging.md#generated-reliability-pipeline) | Idempotent receiver, inbox, and outbox factories | `[GenerateReliabilityPipeline]` | ## Quick Reference @@ -160,6 +161,10 @@ public static partial class OrderLineAggregator { } [GenerateMailbox(typeof(OrderWork), Capacity = 32, BackpressurePolicy = "Wait")] public static partial class OrderMailbox { } +// Reliability pipeline - generated idempotent receiver, inbox, and outbox factories +[GenerateReliabilityPipeline(typeof(AcceptOrder), typeof(string), typeof(OrderAccepted))] +public static partial class OrderReliability { } + // Routing slip - generated ordered itinerary factory [GenerateRoutingSlip(typeof(Order))] public static partial class OrderSlip { } diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index c3c8e36..b657859 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,6 +1,6 @@ # Messaging Generators -PatternKit includes eight messaging-oriented source generators: +PatternKit includes nine messaging-oriented source generators: - for source-generated mediator dispatchers. - for required message-envelope contracts. @@ -10,6 +10,7 @@ PatternKit includes eight messaging-oriented source generators: - for ordered routing-slip factories. - for typed saga/process-manager factories. - for serialized in-process inbox factories. +- for idempotent receiver, inbox, and outbox factories. Use these generators when the message topology is known at compile time and should remain explicit, AOT-friendly, and validated by the compiler. They generate factories and fluent builders; they do not discover handlers from assemblies at runtime and they do not replace brokers, durable queues, or workflow engines. @@ -203,6 +204,37 @@ Example files: - `src/PatternKit.Examples/Messaging/MailboxExample.cs` - `test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs` +## Generated Reliability Pipeline + +`[GenerateReliabilityPipeline]` creates factories for the reliability boundary around a static handler: + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateReliabilityPipeline( + typeof(AcceptOrder), + typeof(string), + typeof(OrderAccepted), + DuplicatePolicy = "ReplayCompleted")] +public static partial class OrderReliability +{ + [ReliabilityHandler] + private static ValueTask Handle( + Message message, + MessageContext context, + CancellationToken cancellationToken) + => new(message.Payload.OrderId); +} +``` + +The generated type emits idempotent receiver, inbox processor, and in-memory outbox factories. Use this path when the reliability boundary is static and should be reviewed in code. Keep using fluent builders when policies, key selectors, or storage wiring are tenant-defined at runtime. + +Example files: + +- `src/PatternKit.Examples/Messaging/ReliabilityExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs` + ## Generated Saga `[GenerateSaga]` emits a process-manager factory from typed transition methods: @@ -243,6 +275,7 @@ Example source: | `PKRS001`-`PKRS003` | Routing Slip | Non-partial host, missing steps, or invalid step signatures. | | `PKSG001`-`PKSG004` | Saga | Non-partial host, missing transitions, invalid transition signatures, or invalid completion checks. | | `PKMB001`-`PKMB005` | Mailbox | Non-partial host, missing handler, invalid handler signatures, or invalid configuration. | +| `PKRP001`-`PKRP005` | Reliability Pipeline | Non-partial host, missing handler, invalid handler/key selector signatures, or invalid configuration. | ## Related Runtime Patterns diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 91a2bac..1dfc0d7 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -52,9 +52,9 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Routing Slip | `RoutingSlip` | Messaging generator | | Enterprise Integration | Saga / Process Manager | `Saga` | Messaging generator | | Enterprise Integration | Mailbox | `Mailbox` | Messaging generator | -| Messaging Reliability | Idempotent Receiver | `IdempotentReceiver` | Tracked in [#213](https://github.com/JerrettDavis/PatternKit/issues/213) | -| Messaging Reliability | Inbox | `InboxProcessor` | Tracked in [#213](https://github.com/JerrettDavis/PatternKit/issues/213) | -| Messaging Reliability | Outbox | `InMemoryOutbox` and dispatcher contracts | Tracked in [#213](https://github.com/JerrettDavis/PatternKit/issues/213) | +| Messaging Reliability | Idempotent Receiver | `IdempotentReceiver` | Reliability pipeline generator | +| Messaging Reliability | Inbox | `InboxProcessor` | Reliability pipeline generator | +| Messaging Reliability | Outbox | `InMemoryOutbox` and dispatcher contracts | Reliability pipeline generator | | Enterprise Integration | Request-Reply | Messaging backplane facade example | Tracked in [#214](https://github.com/JerrettDavis/PatternKit/issues/214) | | Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Tracked in [#214](https://github.com/JerrettDavis/PatternKit/issues/214) | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | diff --git a/docs/patterns/messaging/enterprise-generators.md b/docs/patterns/messaging/enterprise-generators.md index 27a077e..f5f36c9 100644 --- a/docs/patterns/messaging/enterprise-generators.md +++ b/docs/patterns/messaging/enterprise-generators.md @@ -77,7 +77,7 @@ Saga/process-manager generation is documented in [Saga / Process Manager](saga.m Mailbox generation is documented in [Mailbox](mailbox.md). It discovers one `[MailboxHandler]` method plus optional error and event hooks, then emits a configured serialized inbox factory. -Reliability helpers stay runtime-only for now. Their registration is still lifecycle-sensitive and is tracked separately. +Reliability helpers also have a generated path through `[GenerateReliabilityPipeline]`, which emits idempotent receiver, inbox, and outbox factories while keeping durable storage implementation owned by the application. ## Diagnostics diff --git a/docs/patterns/messaging/reliability.md b/docs/patterns/messaging/reliability.md index f014e48..b461690 100644 --- a/docs/patterns/messaging/reliability.md +++ b/docs/patterns/messaging/reliability.md @@ -83,6 +83,32 @@ await outbox.DispatchPendingAsync(dispatcher, cancellationToken); The in-memory outbox records attempts and dispatch timestamps, but it is not durable. A production outbox should persist `OutboxMessage` or an equivalent schema in the same transaction as the business state change, then dispatch records after commit. +## Source-Generated Reliability Pipeline + +`[GenerateReliabilityPipeline]` generates the static factories for a stable idempotent receiver, inbox, and outbox contract: + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateReliabilityPipeline( + typeof(AcceptOrder), + typeof(string), + typeof(OrderAccepted), + DuplicatePolicy = "ReplayCompleted")] +public static partial class OrderReliability +{ + [ReliabilityHandler] + private static ValueTask Handle( + Message message, + MessageContext context, + CancellationToken cancellationToken) + => new(message.Payload.OrderId); +} +``` + +The generated host exposes receiver, inbox, and outbox factory methods while keeping the handler and optional key selector in source. This makes reliability topology visible during code review and importable through normal `IServiceCollection` registration. + ## Boundaries - These APIs help with at-least-once processing; they do not provide exactly-once delivery. @@ -106,6 +132,7 @@ The in-memory outbox records attempts and dispatch timestamps, but it is not dur - - - +- ## Example Source diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 7ff1637..2a79ab7 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -92,6 +92,7 @@ public sealed record SourceGeneratorApplicationSuiteExample(Func Run); public sealed record CqrsDispatcherExample(Func> RunFluentAsync, Func> RunSourceGeneratedAsync); public sealed record GeneratedMailboxExample(MailboxExampleRunner Runner); +public sealed record GeneratedReliabilityPipelineExample(ReliabilityExampleRunner Runner); public sealed record ResilientCheckoutMailboxesExample(Func Run); public sealed record MessagingBackplaneFacadeExample(Func> RunAsync); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); @@ -135,6 +136,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddEnterpriseMessagingWorkflowSuiteExample() .AddCqrsDispatcherExample() .AddGeneratedMailboxExample() + .AddGeneratedReliabilityPipelineExample() .AddResilientCheckoutMailboxesExample() .AddMessagingBackplaneFacadeExample() .AddPrototypeGameCharacterFactoryExample() @@ -390,6 +392,13 @@ public static IServiceCollection AddGeneratedMailboxExample(this IServiceCollect return services.RegisterExample("Generated Mailbox", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddGeneratedReliabilityPipelineExample(this IServiceCollection services) + { + services.AddSingleton(new ReliabilityExampleRunner(ReliabilityExample.RunFluentAsync, ReliabilityExample.RunGeneratedAsync)); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Generated Reliability Pipeline", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddResilientCheckoutMailboxesExample(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/PatternKit.Examples/Messaging/ReliabilityExample.cs b/src/PatternKit.Examples/Messaging/ReliabilityExample.cs index 679e77d..58ce8e1 100644 --- a/src/PatternKit.Examples/Messaging/ReliabilityExample.cs +++ b/src/PatternKit.Examples/Messaging/ReliabilityExample.cs @@ -1,6 +1,7 @@ using PatternKit.Messaging; using PatternKit.Messaging.Mailboxes; using PatternKit.Messaging.Reliability; +using PatternKit.Generators.Messaging; namespace PatternKit.Examples.Messaging; @@ -10,7 +11,10 @@ namespace PatternKit.Examples.Messaging; public static class ReliabilityExample { /// Runs an idempotent order inbox and returns dispatched outbox payloads. - public static async ValueTask> RunAsync() + public static ValueTask> RunAsync() => RunFluentAsync(); + + /// Runs the fluent idempotent receiver and outbox path. + public static async ValueTask> RunFluentAsync() { var store = new InMemoryIdempotencyStore(); var outbox = new InMemoryOutbox(); @@ -54,6 +58,38 @@ await outbox.DispatchPendingAsync(new DelegateOutboxDispatcherRuns the generated idempotent receiver, inbox processor, and outbox path. + public static async ValueTask> RunGeneratedAsync() + { + var store = new InMemoryIdempotencyStore(); + var inbox = GeneratedReliabilityOrderPipeline.CreateInbox(store); + var outbox = GeneratedReliabilityOrderPipeline.CreateOutbox(); + var dispatched = new List(); + + var command = Message + .Create(new AcceptOrder("order-42")) + .WithIdempotencyKey("accept-order-42"); + + var first = await inbox.ProcessAsync(command); + _ = await inbox.ProcessAsync(command); + + if (first.Processed) + { + await outbox.EnqueueAsync( + Message.Create(new ReliabilityOrderAccepted(first.Result!)), + id: $"accepted-{first.Result}"); + } + + await outbox.DispatchPendingAsync(new DelegateOutboxDispatcher( + (record, _) => + { + dispatched.Add(record.Message.Payload.OrderId); + return default; + })); + + return dispatched; + } } /// Reliability example command payload. @@ -62,6 +98,30 @@ public sealed record AcceptOrder(string OrderId); /// Reliability example event payload. public sealed record ReliabilityOrderAccepted(string OrderId); +/// DI-friendly runner exposing fluent and generated reliability paths. +public sealed record ReliabilityExampleRunner( + Func>> RunFluentAsync, + Func>> RunGeneratedAsync); + +/// Source-generated reliability pipeline used by the production-shaped example. +[GenerateReliabilityPipeline( + typeof(AcceptOrder), + typeof(string), + typeof(ReliabilityOrderAccepted), + DuplicatePolicy = "ReplayCompleted", + ReceiverFactoryName = "CreateOrderReceiver", + InboxFactoryName = "CreateInbox", + OutboxFactoryName = "CreateOutbox")] +public static partial class GeneratedReliabilityOrderPipeline +{ + [ReliabilityHandler] + private static ValueTask Handle( + Message message, + MessageContext context, + CancellationToken cancellationToken) + => new(message.Payload.OrderId); +} + internal sealed class DelegateOutboxDispatcher : IOutboxDispatcher { private readonly Func, CancellationToken, ValueTask> _dispatch; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 4e39d82..39c12c5 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -272,6 +272,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Mailbox"], ["serialized inbox", "source-generated factory", "DI composition"]), + Descriptor( + "Generated Reliability Pipeline", + "src/PatternKit.Examples/Messaging/ReliabilityExample.cs", + "test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs", + "docs/examples/generated-reliability-pipeline.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["IdempotentReceiver", "Inbox", "Outbox"], + ["duplicate suppression", "source-generated factories", "DI composition"]), Descriptor( "Resilient Checkout and Collaborating Mailboxes", "src/PatternKit.Examples/Messaging/ResilientCheckoutDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index bd80c5d..44c1114 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -471,40 +471,40 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/patterns/messaging/reliability.md", "src/PatternKit.Core/Messaging/Reliability/IdempotentReceiver.cs", "test/PatternKit.Tests/Messaging/Reliability/IdempotentReceiverTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/ReliabilityPipelineGenerator.cs", + "test/PatternKit.Generators.Tests/ReliabilityPipelineGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/213", - "docs/examples/enterprise-messaging-workflows.md", + "docs/examples/generated-reliability-pipeline.md", "src/PatternKit.Examples/Messaging/ReliabilityExample.cs", "test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs", - ["runtime duplicate suppression", "generated reliability path tracked", "at-least-once example"]), + ["runtime duplicate suppression", "generated reliability pipeline", "DI-importable at-least-once example"]), Pattern("Inbox", PatternFamily.MessagingReliability, "docs/patterns/messaging/reliability.md", "src/PatternKit.Core/Messaging/Reliability/InboxProcessor.cs", "test/PatternKit.Tests/Messaging/Reliability/IdempotentReceiverTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/ReliabilityPipelineGenerator.cs", + "test/PatternKit.Generators.Tests/ReliabilityPipelineGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/213", - "docs/examples/enterprise-messaging-workflows.md", + "docs/examples/generated-reliability-pipeline.md", "src/PatternKit.Examples/Messaging/ReliabilityExample.cs", "test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs", - ["runtime inbox boundary", "generated inbox path tracked", "reliable handoff example"]), + ["runtime inbox boundary", "generated inbox factory", "DI-importable reliable handoff example"]), Pattern("Outbox", PatternFamily.MessagingReliability, "docs/patterns/messaging/reliability.md", "src/PatternKit.Core/Messaging/Reliability/InMemoryOutbox.cs", "test/PatternKit.Tests/Messaging/Reliability/OutboxTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/ReliabilityPipelineGenerator.cs", + "test/PatternKit.Generators.Tests/ReliabilityPipelineGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/213", - "docs/examples/enterprise-messaging-workflows.md", + "docs/examples/generated-reliability-pipeline.md", "src/PatternKit.Examples/Messaging/ReliabilityExample.cs", "test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs", - ["runtime outbox", "generated outbox path tracked", "dispatch handoff example"]), + ["runtime outbox", "generated outbox factory", "DI-importable dispatch handoff example"]), Pattern("Request-Reply", PatternFamily.EnterpriseIntegration, "docs/examples/messaging-backplane-facade.md", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/ReliabilityAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/ReliabilityAttributes.cs new file mode 100644 index 0000000..56649ee --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/ReliabilityAttributes.cs @@ -0,0 +1,54 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates idempotent receiver, inbox processor, and outbox factories for a reliable messaging pipeline. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateReliabilityPipelineAttribute : Attribute +{ + /// Creates a reliability-pipeline generator attribute. + public GenerateReliabilityPipelineAttribute(Type payloadType, Type resultType, Type outboxPayloadType) + { + PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + ResultType = resultType ?? throw new ArgumentNullException(nameof(resultType)); + OutboxPayloadType = outboxPayloadType ?? throw new ArgumentNullException(nameof(outboxPayloadType)); + } + + /// Input payload handled by the idempotent receiver and inbox. + public Type PayloadType { get; } + + /// Result type returned by the reliable handler. + public Type ResultType { get; } + + /// Payload type stored in the generated outbox. + public Type OutboxPayloadType { get; } + + /// Name of the generated idempotent receiver factory method. + public string ReceiverFactoryName { get; set; } = "CreateReceiver"; + + /// Name of the generated inbox processor factory method. + public string InboxFactoryName { get; set; } = "CreateInbox"; + + /// Name of the generated outbox factory method. + public string OutboxFactoryName { get; set; } = "CreateOutbox"; + + /// Duplicate handling policy: Suppress or ReplayCompleted. + public string DuplicatePolicy { get; set; } = "Suppress"; + + /// Missing idempotency-key policy: Reject or Process. + public string MissingKeyPolicy { get; set; } = "Reject"; +} + +/// +/// Marks the handler used by a generated reliability pipeline. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ReliabilityHandlerAttribute : Attribute; + +/// +/// Marks an optional idempotency key selector used by a generated reliability pipeline. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ReliabilityKeySelectorAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index d731338..800c53a 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -179,6 +179,11 @@ PKMB002 | PatternKit.Generators.Messaging | Error | Mailbox must declare exactly PKMB003 | PatternKit.Generators.Messaging | Error | Mailbox handler signature is invalid. PKMB004 | PatternKit.Generators.Messaging | Error | Mailbox optional handler signature is invalid. PKMB005 | PatternKit.Generators.Messaging | Error | Mailbox generator configuration is invalid. +PKRP001 | PatternKit.Generators.Messaging | Error | Reliability pipeline type must be partial. +PKRP002 | PatternKit.Generators.Messaging | Error | Reliability pipeline must declare exactly one handler. +PKRP003 | PatternKit.Generators.Messaging | Error | Reliability pipeline handler signature is invalid. +PKRP004 | PatternKit.Generators.Messaging | Error | Reliability key selector signature is invalid. +PKRP005 | PatternKit.Generators.Messaging | Error | Reliability pipeline configuration is invalid. PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be partial. PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/ReliabilityPipelineGenerator.cs b/src/PatternKit.Generators/Messaging/ReliabilityPipelineGenerator.cs new file mode 100644 index 0000000..2c0574f --- /dev/null +++ b/src/PatternKit.Generators/Messaging/ReliabilityPipelineGenerator.cs @@ -0,0 +1,271 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class ReliabilityPipelineGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKRP001", + "Reliability pipeline type must be partial", + "Type '{0}' is marked with [GenerateReliabilityPipeline] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingHandler = new( + "PKRP002", + "Reliability pipeline handler is missing", + "Type '{0}' is marked with [GenerateReliabilityPipeline] but must declare exactly one [ReliabilityHandler] method", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidHandler = new( + "PKRP003", + "Reliability pipeline handler signature is invalid", + "Reliability handler '{0}' must be static and return ValueTask with Message, MessageContext, and CancellationToken parameters", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidKeySelector = new( + "PKRP004", + "Reliability key selector signature is invalid", + "Reliability key selector '{0}' must be static and return string? with Message and MessageContext parameters", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKRP005", + "Reliability configuration is invalid", + "Generated reliability pipeline configuration is invalid: {0}", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateReliabilityPipelineAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.GenerateReliabilityPipelineAttribute"); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var payloadType = GetConstructorType(attribute, 0); + var resultType = GetConstructorType(attribute, 1); + var outboxPayloadType = GetConstructorType(attribute, 2); + if (payloadType is null || resultType is null || outboxPayloadType is null) + return; + + var handlers = GetMarkedMethods(type, "PatternKit.Generators.Messaging.ReliabilityHandlerAttribute"); + if (handlers.Count != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + var handler = handlers[0]; + if (!IsHandler(handler, payloadType, resultType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handler.Locations.FirstOrDefault(), handler.Name)); + return; + } + + var keySelectors = GetMarkedMethods(type, "PatternKit.Generators.Messaging.ReliabilityKeySelectorAttribute"); + if (keySelectors.Count > 1) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidKeySelector, node.Identifier.GetLocation(), type.Name)); + return; + } + + var keySelector = keySelectors.FirstOrDefault(); + if (keySelector is not null && !IsKeySelector(keySelector, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidKeySelector, keySelector.Locations.FirstOrDefault(), keySelector.Name)); + return; + } + + var duplicatePolicy = GetNamedString(attribute, "DuplicatePolicy") ?? "Suppress"; + if (!TryNormalizeDuplicatePolicy(duplicatePolicy, out var normalizedDuplicatePolicy)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), $"unsupported duplicate policy '{duplicatePolicy}'")); + return; + } + + var missingKeyPolicy = GetNamedString(attribute, "MissingKeyPolicy") ?? "Reject"; + if (!TryNormalizeMissingKeyPolicy(missingKeyPolicy, out var normalizedMissingKeyPolicy)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), $"unsupported missing key policy '{missingKeyPolicy}'")); + return; + } + + var config = new ReliabilityPipelineConfig( + GetNamedString(attribute, "ReceiverFactoryName") ?? "CreateReceiver", + GetNamedString(attribute, "InboxFactoryName") ?? "CreateInbox", + GetNamedString(attribute, "OutboxFactoryName") ?? "CreateOutbox", + normalizedDuplicatePolicy, + normalizedMissingKeyPolicy); + + context.AddSource($"{type.Name}.ReliabilityPipeline.g.cs", SourceText.From( + GenerateSource(type, payloadType, resultType, outboxPayloadType, handler.Name, keySelector?.Name, config), + Encoding.UTF8)); + } + + private static INamedTypeSymbol? GetConstructorType(AttributeData attribute, int index) + => attribute.ConstructorArguments.Length > index + ? attribute.ConstructorArguments[index].Value as INamedTypeSymbol + : null; + + private static List GetMarkedMethods(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToList(); + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol payloadType, INamedTypeSymbol resultType) + => method.IsStatic && + IsValueTaskOf(method.ReturnType, resultType) && + method.Parameters.Length == 3 && + IsMessageOf(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static bool IsKeySelector(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_String && + method.Parameters.Length == 2 && + IsMessageOf(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsValueTaskOf(ITypeSymbol type, INamedTypeSymbol resultType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], resultType); + + private static bool IsMessageOf(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + INamedTypeSymbol resultType, + INamedTypeSymbol outboxPayloadType, + string handlerName, + string? keySelectorName, + ReliabilityPipelineConfig config) + { + var payload = payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var result = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var outboxPayload = outboxPayloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Reliability.IdempotentReceiver<") + .Append(payload).Append(", ").Append(result).Append("> ") + .Append(config.ReceiverFactoryName) + .AppendLine("(global::PatternKit.Messaging.Reliability.IIdempotencyStore store)"); + sb.Append(" => global::PatternKit.Messaging.Reliability.IdempotentReceiver<") + .Append(payload).Append(", ").Append(result).AppendLine(">.Create(store, " + handlerName + ")"); + if (keySelectorName is not null) + sb.Append(" .KeyBy(").Append(keySelectorName).AppendLine(")"); + sb.Append(" .OnDuplicate(global::PatternKit.Messaging.Reliability.DuplicateMessagePolicy.") + .Append(config.DuplicatePolicy).AppendLine(")"); + sb.Append(" .OnMissingKey(global::PatternKit.Messaging.Reliability.MissingIdempotencyKeyPolicy.") + .Append(config.MissingKeyPolicy).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(); + + sb.Append(" public static global::PatternKit.Messaging.Reliability.InboxProcessor<") + .Append(payload).Append(", ").Append(result).Append("> ") + .Append(config.InboxFactoryName) + .AppendLine("(global::PatternKit.Messaging.Reliability.IIdempotencyStore store)"); + sb.Append(" => global::PatternKit.Messaging.Reliability.InboxProcessor<") + .Append(payload).Append(", ").Append(result).AppendLine(">.Create(" + config.ReceiverFactoryName + "(store));"); + sb.AppendLine(); + + sb.Append(" public static global::PatternKit.Messaging.Reliability.InMemoryOutbox<") + .Append(outboxPayload).Append("> ") + .Append(config.OutboxFactoryName) + .AppendLine("()"); + sb.Append(" => new global::PatternKit.Messaging.Reliability.InMemoryOutbox<") + .Append(outboxPayload).AppendLine(">();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static bool TryNormalizeDuplicatePolicy(string value, out string normalized) + { + normalized = value; + if (string.Equals(value, "Suppress", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Suppress"; + else if (string.Equals(value, "ReplayCompleted", System.StringComparison.OrdinalIgnoreCase)) + normalized = "ReplayCompleted"; + else + return false; + + return true; + } + + private static bool TryNormalizeMissingKeyPolicy(string value, out string normalized) + { + normalized = value; + if (string.Equals(value, "Reject", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Reject"; + else if (string.Equals(value, "Process", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Process"; + else + return false; + + return true; + } + + private readonly record struct ReliabilityPipelineConfig( + string ReceiverFactoryName, + string InboxFactoryName, + string OutboxFactoryName, + string DuplicatePolicy, + string MissingKeyPolicy); +} diff --git a/test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs index b3917e1..23f21c9 100644 --- a/test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs +++ b/test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs @@ -1,16 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.Messaging; +using PatternKit.Examples.ProductionReadiness; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Examples.Tests.Messaging; -public sealed class ReliabilityExampleTests +[Feature("Generated reliability pipeline example")] +public sealed class ReliabilityExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) { - [Scenario("RunAsync DispatchesOneOutboxMessageForDuplicateInput")] + [Scenario("Fluent and generated reliability paths dispatch one outbox message for duplicate input")] [Fact] - public async Task RunAsync_DispatchesOneOutboxMessageForDuplicateInput() - { - var dispatched = await ReliabilityExample.RunAsync(); + public Task Fluent_And_Generated_Reliability_Paths_Dispatch_One_Outbox_Message_For_Duplicate_Input() + => Given("reliability example entry points", () => + new ReliabilityExampleRunner(ReliabilityExample.RunFluentAsync, ReliabilityExample.RunGeneratedAsync)) + .When("running both reliability paths", async ValueTask (runner) => new ReliabilityExampleRun( + await runner.RunFluentAsync(), + await runner.RunGeneratedAsync())) + .Then("the fluent path dispatches only one accepted event", result => + ScenarioExpect.Equal(["order-42"], result.FluentDispatched)) + .And("the generated path matches the fluent behavior", result => + ScenarioExpect.Equal(result.FluentDispatched, result.GeneratedDispatched)) + .AssertPassed(); - ScenarioExpect.Equal(["order-42"], dispatched); - } + [Scenario("Generated reliability pipeline example is importable through IServiceCollection")] + [Fact] + public Task Generated_Reliability_Pipeline_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection using the PatternKit reliability extension", () => + { + var services = new ServiceCollection(); + services.AddGeneratedReliabilityPipelineExample(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the generated reliability pipeline example", async ValueTask (provider) => + { + using (provider) + { + var example = provider.GetRequiredService(); + var dispatched = await example.Runner.RunGeneratedAsync(); + var descriptor = provider.GetServices() + .Single(descriptor => descriptor.ExampleName == "Generated Reliability Pipeline"); + + return new ReliabilityImportRun(dispatched, descriptor.Integration); + } + }) + .Then("the generated runner dispatches one accepted order", result => + ScenarioExpect.Equal(["order-42"], result.Dispatched)) + .And("the descriptor advertises DI source generation and messaging", result => + result.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection) + && result.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator) + && result.Integration.HasFlag(ExampleIntegrationSurface.Messaging)) + .AssertPassed(); + + private sealed record ReliabilityExampleRun( + IReadOnlyList FluentDispatched, + IReadOnlyList GeneratedDispatched); + + private sealed record ReliabilityImportRun(IReadOnlyList Dispatched, ExampleIntegrationSurface Integration); } diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 8b857bc..9720e44 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -121,10 +121,7 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() ScenarioExpect.Equal( [ "Abstract Factory has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/207", - "Idempotent Receiver has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", - "Inbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206", - "Outbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Publish-Subscribe has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", "Request-Reply has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214" ], tracked); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 86d1126..d730690 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -89,6 +89,9 @@ private enum TestTrigger { typeof(MailboxHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(MailboxErrorHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(MailboxEventSinkAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateReliabilityPipelineAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(ReliabilityHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(ReliabilityKeySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateMessageEnvelopeAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessageEnvelopeHeaderAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, { typeof(ObserverAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -367,6 +370,14 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf BackpressurePolicy = "Reject", ErrorPolicy = "Continue" }; + var reliability = new GenerateReliabilityPipelineAttribute(typeof(string), typeof(int), typeof(decimal)) + { + ReceiverFactoryName = "BuildReceiver", + InboxFactoryName = "BuildInbox", + OutboxFactoryName = "BuildOutbox", + DuplicatePolicy = "ReplayCompleted", + MissingKeyPolicy = "Process" + }; var envelope = new GenerateMessageEnvelopeAttribute(typeof(string)) { FactoryName = "BuildEnvelope", @@ -425,6 +436,14 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal(4, mailbox.Capacity); ScenarioExpect.Equal("Reject", mailbox.BackpressurePolicy); ScenarioExpect.Equal("Continue", mailbox.ErrorPolicy); + ScenarioExpect.Equal(typeof(string), reliability.PayloadType); + ScenarioExpect.Equal(typeof(int), reliability.ResultType); + ScenarioExpect.Equal(typeof(decimal), reliability.OutboxPayloadType); + ScenarioExpect.Equal("BuildReceiver", reliability.ReceiverFactoryName); + ScenarioExpect.Equal("BuildInbox", reliability.InboxFactoryName); + ScenarioExpect.Equal("BuildOutbox", reliability.OutboxFactoryName); + ScenarioExpect.Equal("ReplayCompleted", reliability.DuplicatePolicy); + ScenarioExpect.Equal("Process", reliability.MissingKeyPolicy); ScenarioExpect.Equal(typeof(string), envelope.PayloadType); ScenarioExpect.Equal("BuildEnvelope", envelope.FactoryName); ScenarioExpect.Equal("BuildContext", envelope.ContextFactoryName); @@ -448,6 +467,9 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(typeof(string), null!, typeof(decimal))); ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(typeof(string), typeof(int), null!)); ScenarioExpect.Throws(() => new GenerateMailboxAttribute(null!)); + ScenarioExpect.Throws(() => new GenerateReliabilityPipelineAttribute(null!, typeof(int), typeof(decimal))); + ScenarioExpect.Throws(() => new GenerateReliabilityPipelineAttribute(typeof(string), null!, typeof(decimal))); + ScenarioExpect.Throws(() => new GenerateReliabilityPipelineAttribute(typeof(string), typeof(int), null!)); ScenarioExpect.Throws(() => new GenerateMessageEnvelopeAttribute(null!)); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("", typeof(string))); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("tenant-id", null!)); @@ -460,6 +482,8 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.IsType(new MailboxHandlerAttribute()); ScenarioExpect.IsType(new MailboxErrorHandlerAttribute()); ScenarioExpect.IsType(new MailboxEventSinkAttribute()); + ScenarioExpect.IsType(new ReliabilityHandlerAttribute()); + ScenarioExpect.IsType(new ReliabilityKeySelectorAttribute()); ScenarioExpect.IsType(new FlyweightFactoryAttribute()); ScenarioExpect.IsType(new IteratorStepAttribute()); ScenarioExpect.IsType(new TraversalIteratorAttribute()); diff --git a/test/PatternKit.Generators.Tests/ReliabilityPipelineGeneratorTests.cs b/test/PatternKit.Generators.Tests/ReliabilityPipelineGeneratorTests.cs new file mode 100644 index 0000000..1daf730 --- /dev/null +++ b/test/PatternKit.Generators.Tests/ReliabilityPipelineGeneratorTests.cs @@ -0,0 +1,190 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class ReliabilityPipelineGeneratorTests +{ + [Scenario("Generates idempotent receiver inbox and outbox factories")] + [Fact] + public void GeneratesIdempotentReceiverInboxAndOutboxFactories() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + using PatternKit.Messaging.Reliability; + + namespace MyApp; + + public sealed record AcceptOrder(string Id); + public sealed record OrderAccepted(string Id); + + [GenerateReliabilityPipeline( + typeof(AcceptOrder), + typeof(string), + typeof(OrderAccepted), + ReceiverFactoryName = "BuildReceiver", + InboxFactoryName = "BuildInbox", + OutboxFactoryName = "BuildOutbox", + DuplicatePolicy = "ReplayCompleted", + MissingKeyPolicy = "Process")] + public static partial class OrderReliability + { + [ReliabilityHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) + => new(message.Payload.Id); + + [ReliabilityKeySelector] + private static string? SelectKey(Message message, MessageContext context) + => message.Headers.IdempotencyKey ?? message.Payload.Id; + } + + public static class Demo + { + public static async ValueTask RunAsync() + { + var inbox = OrderReliability.BuildInbox(new InMemoryIdempotencyStore()); + var result = await inbox.ProcessAsync(Message.Create(new AcceptOrder("order-1"))); + var outbox = OrderReliability.BuildOutbox(); + return result.Result; + } + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesIdempotentReceiverInboxAndOutboxFactories)); + var gen = new ReliabilityPipelineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("OrderReliability.ReliabilityPipeline.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("BuildReceiver", text); + ScenarioExpect.Contains(".KeyBy(SelectKey)", text); + ScenarioExpect.Contains(".OnDuplicate(global::PatternKit.Messaging.Reliability.DuplicateMessagePolicy.ReplayCompleted)", text); + ScenarioExpect.Contains(".OnMissingKey(global::PatternKit.Messaging.Reliability.MissingIdempotencyKeyPolicy.Process)", text); + ScenarioExpect.Contains("BuildInbox", text); + ScenarioExpect.Contains("BuildOutbox", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial reliability pipeline")] + [Fact] + public void ReportsDiagnosticForNonPartialReliabilityPipeline() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record AcceptOrder(string Id); + public sealed record OrderAccepted(string Id); + + [GenerateReliabilityPipeline(typeof(AcceptOrder), typeof(string), typeof(OrderAccepted))] + public static class OrderReliability; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialReliabilityPipeline)); + var gen = new ReliabilityPipelineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRP001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing reliability handler")] + [Fact] + public void ReportsDiagnosticForMissingReliabilityHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record AcceptOrder(string Id); + public sealed record OrderAccepted(string Id); + + [GenerateReliabilityPipeline(typeof(AcceptOrder), typeof(string), typeof(OrderAccepted))] + public static partial class OrderReliability; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingReliabilityHandler)); + var gen = new ReliabilityPipelineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRP002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid reliability handler")] + [Fact] + public void ReportsDiagnosticForInvalidReliabilityHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record AcceptOrder(string Id); + public sealed record OrderAccepted(string Id); + + [GenerateReliabilityPipeline(typeof(AcceptOrder), typeof(string), typeof(OrderAccepted))] + public static partial class OrderReliability + { + [ReliabilityHandler] + private static string Handle(AcceptOrder command) => command.Id; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidReliabilityHandler)); + var gen = new ReliabilityPipelineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRP003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid reliability policy")] + [Fact] + public void ReportsDiagnosticForInvalidReliabilityPolicy() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record AcceptOrder(string Id); + public sealed record OrderAccepted(string Id); + + [GenerateReliabilityPipeline(typeof(AcceptOrder), typeof(string), typeof(OrderAccepted), DuplicatePolicy = "ReplayForever")] + public static partial class OrderReliability + { + [ReliabilityHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) + => new(message.Payload.Id); + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidReliabilityPolicy)); + var gen = new ReliabilityPipelineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRP005", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Reliability.IdempotentReceiver<,>).Assembly.Location)); +}