From c5802c2e6d0cd78981a9dd5420596a598fd772ce Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Wed, 20 May 2026 22:06:18 -0500 Subject: [PATCH] feat: add dead letter channel pattern support --- .../examples/generated-dead-letter-channel.md | 29 ++ docs/examples/toc.yml | 3 + docs/generators/dead-letter-channel.md | 41 +++ docs/generators/index.md | 9 + docs/generators/messaging.md | 27 +- docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + .../patterns/messaging/dead-letter-channel.md | 50 +++ docs/patterns/toc.yml | 2 + .../Reliability/DeadLetterChannel.cs | 343 ++++++++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 11 + .../FulfillmentDeadLetterChannelExample.cs | 124 +++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 + .../Messaging/DeadLetterChannelAttributes.cs | 34 ++ .../AnalyzerReleases.Unshipped.md | 3 + .../Messaging/DeadLetterChannelGenerator.cs | 172 +++++++++ ...tternKitExampleDependencyInjectionTests.cs | 2 + ...ulfillmentDeadLetterChannelExampleTests.cs | 66 ++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 18 + .../DeadLetterChannelGeneratorTests.cs | 138 +++++++ .../Reliability/DeadLetterChannelTests.cs | 106 ++++++ 23 files changed, 1204 insertions(+), 2 deletions(-) create mode 100644 docs/examples/generated-dead-letter-channel.md create mode 100644 docs/generators/dead-letter-channel.md create mode 100644 docs/patterns/messaging/dead-letter-channel.md create mode 100644 src/PatternKit.Core/Messaging/Reliability/DeadLetterChannel.cs create mode 100644 src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs create mode 100644 src/PatternKit.Generators.Abstractions/Messaging/DeadLetterChannelAttributes.cs create mode 100644 src/PatternKit.Generators/Messaging/DeadLetterChannelGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs create mode 100644 test/PatternKit.Generators.Tests/DeadLetterChannelGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Messaging/Reliability/DeadLetterChannelTests.cs diff --git a/docs/examples/generated-dead-letter-channel.md b/docs/examples/generated-dead-letter-channel.md new file mode 100644 index 00000000..7e12df00 --- /dev/null +++ b/docs/examples/generated-dead-letter-channel.md @@ -0,0 +1,29 @@ +# Generated Dead Letter Channel + +The generated dead-letter channel example models a checkout fulfillment boundary where failed warehouse or carrier messages are captured with reason, attempt count, original headers, and replay metadata. + +Production applications can import the example through `IServiceCollection`: + +```csharp +var services = new ServiceCollection(); +services.AddFulfillmentDeadLetterChannelExample(); + +using var provider = services.BuildServiceProvider(); +var workflow = provider.GetRequiredService(); + +var summary = workflow.Capture( + FulfillmentDeadLetterChannelExample.CreateCommand("order-100"), + "carrier timeout"); +``` + +The example includes: + +- a fluent `DeadLetterChannel` path +- a source-generated `[GenerateDeadLetterChannel]` path +- an importable `FulfillmentDeadLetterWorkflow` +- TinyBDD tests for fluent, generated, and DI usage + +Files: + +- `src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 2d87fd26..ba63315c 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -64,6 +64,9 @@ - name: Generated Claim Check href: generated-claim-check.md +- name: Generated Dead Letter Channel + href: generated-dead-letter-channel.md + - name: Generated Recipient List href: generated-recipient-list.md diff --git a/docs/generators/dead-letter-channel.md b/docs/generators/dead-letter-channel.md new file mode 100644 index 00000000..fbe0475a --- /dev/null +++ b/docs/generators/dead-letter-channel.md @@ -0,0 +1,41 @@ +# Dead Letter Channel Generator + +`[GenerateDeadLetterChannel]` emits a factory for `DeadLetterChannel` from a partial host and a store factory method. + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging.Reliability; + +[GenerateDeadLetterChannel( + typeof(FulfillmentCommand), + FactoryName = "CreateChannel", + ChannelName = "fulfillment-dead-letter", + Source = "checkout.fulfillment", + IdPrefix = "fulfillment-dead")] +public static partial class FulfillmentDeadLetters +{ + [DeadLetterStoreFactory] + private static IDeadLetterStore CreateStore() + => new InMemoryDeadLetterStore(); +} +``` + +The generated factory: + +- creates a named dead-letter channel +- records the configured source in captured metadata +- uses the marked store factory for application-owned persistence +- generates deterministic ids from the configured prefix and message id +- enables exception detail capture by default + +## Diagnostics + +| ID | Meaning | +| --- | --- | +| `PKDL001` | The host type marked with `[GenerateDeadLetterChannel]` must be partial. | +| `PKDL002` | The host must declare exactly one `[DeadLetterStoreFactory]` method. | +| `PKDL003` | The store factory must be static, parameterless, and return `IDeadLetterStore`. | + +## Example + +See [Generated Dead Letter Channel](../examples/generated-dead-letter-channel.md). diff --git a/docs/generators/index.md b/docs/generators/index.md index cadc3f17..aaeb2614 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -71,6 +71,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | | [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` | | [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` | +| [**Dead Letter Channel**](dead-letter-channel.md) | Failed-message capture and replay handoff | `[GenerateDeadLetterChannel]` | | [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` | | [**Recipient List**](messaging.md#generated-recipient-list) | Recipient fan-out factories | `[GenerateRecipientList]` | | [**Splitter / Aggregator**](messaging.md#generated-splitter-and-aggregator) | Split/rejoin message routing factories | `[GenerateSplitter]` / `[GenerateAggregator]` | @@ -165,6 +166,14 @@ public static partial class LegacyOrderAcl { } [GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted))] public static partial class PartnerOrderTranslator { } +// Claim check - external payload storage reference +[GenerateClaimCheck(typeof(LargeOrderDocument), StoreName = "document-archive")] +public static partial class LargeDocumentClaims { } + +// Dead-letter channel - failed message capture and replay handoff +[GenerateDeadLetterChannel(typeof(FulfillmentCommand), ChannelName = "fulfillment-dead-letter")] +public static partial class FulfillmentDeadLetters { } + // Visitor - type-safe double dispatch [GenerateVisitor] public interface IDocumentVisitor { } diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index 1b4eef8a..f1a447c0 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,11 +1,12 @@ # Messaging Generators -PatternKit includes twelve messaging-oriented source generators: +PatternKit includes thirteen messaging-oriented source generators: - for source-generated mediator dispatchers. - for required message-envelope contracts. - for partner and transport message normalization. - for external payload storage references. +- for failed-message capture and replay handoff. - for content-based message routers. - for recipient-list fan-out. - and for split/rejoin routing. @@ -105,6 +106,27 @@ Example source: - `src/PatternKit.Examples/Messaging/LargeDocumentClaimCheckExample.cs` - `test/PatternKit.Examples.Tests/Messaging/LargeDocumentClaimCheckExampleTests.cs` +## Generated Dead Letter Channel + +`[GenerateDeadLetterChannel]` creates a `DeadLetterChannel` factory with a pluggable dead-letter store: + +```csharp +[GenerateDeadLetterChannel(typeof(FulfillmentCommand), ChannelName = "fulfillment-dead-letter")] +public static partial class FulfillmentDeadLetters +{ + [DeadLetterStoreFactory] + private static IDeadLetterStore CreateStore() + => new InMemoryDeadLetterStore(); +} +``` + +See [Dead Letter Channel Generator](dead-letter-channel.md) for diagnostics and examples. + +Example source: + +- `src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs` + ## Generated Content Router `[GenerateContentRouter]` creates a `ContentRouter` factory from static route methods: @@ -346,6 +368,8 @@ Example source: | --- | --- | --- | | `PKDSP001`-`PKDSP004` | Dispatcher | Invalid dispatcher configuration or handler registration. | | `PKME001`-`PKME004` | Message Envelope | Non-partial host, missing headers, invalid header configuration, or duplicate names. | +| `PKCC001`-`PKCC003` | Claim Check | Non-partial host, missing store factory, or invalid store factory signature. | +| `PKDL001`-`PKDL003` | Dead Letter Channel | Non-partial host, missing store factory, or invalid store factory signature. | | `PKCR001`-`PKCR005` | Content Router | Non-partial host, missing routes, invalid signatures, duplicate defaults, or duplicate route identity. | | `PKRL001`-`PKRL004` | Recipient List | Non-partial host, missing recipients, invalid signatures, or duplicate recipient identity. | | `PKSA001`-`PKSA006` | Splitter / Aggregator | Non-partial host, missing contract methods, invalid signatures, or invalid duplicate policy. | @@ -358,6 +382,7 @@ Example source: ## Related Runtime Patterns - [Message Envelope and Context](../patterns/messaging/message-envelope.md) +- [Dead Letter Channel](../patterns/messaging/dead-letter-channel.md) - [Enterprise Message Routing](../patterns/messaging/message-routing.md) - [Routing Slip](../patterns/messaging/routing-slip.md) - [Saga / Process Manager](../patterns/messaging/saga.md) diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index ed7bbc25..a104e74c 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -70,6 +70,9 @@ - name: Claim Check href: claim-check.md +- name: Dead Letter Channel + href: dead-letter-channel.md + - name: Prototype href: prototype.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 27c3e209..6fcc4315 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -47,6 +47,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Message Envelope | `Message`, headers, context | Messaging generator | | Enterprise Integration | Message Translator | `MessageTranslator` | Message Translator generator | | Enterprise Integration | Claim Check | `ClaimCheck` | Claim Check generator | +| Enterprise Integration | Dead Letter Channel | `DeadLetterChannel` | Dead Letter Channel generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | | Enterprise Integration | Recipient List | `RecipientList` | Messaging generator | | Enterprise Integration | Splitter | `Splitter` | Messaging generator | diff --git a/docs/patterns/messaging/dead-letter-channel.md b/docs/patterns/messaging/dead-letter-channel.md new file mode 100644 index 00000000..3596c8aa --- /dev/null +++ b/docs/patterns/messaging/dead-letter-channel.md @@ -0,0 +1,50 @@ +# Dead Letter Channel + +Dead Letter Channel captures messages that cannot be processed or delivered after the owning pipeline has exhausted its normal handling path. PatternKit keeps the channel application-owned: you choose the store, source name, failure reason, attempt count, and replay handoff. + +Use `DeadLetterChannel` when a message should be preserved for operations instead of being dropped or retried forever. + +```csharp +var store = new InMemoryDeadLetterStore(); + +var channel = DeadLetterChannel.Create("fulfillment-dead-letter") + .FromSource("checkout.fulfillment") + .UseStore(store) + .UseIds((message, reason, context) => "fulfillment-dead:" + message.Headers.MessageId) + .Build(); + +var deadLetter = await channel.CaptureAsync( + command, + "carrier timeout", + exception, + attempts: 4, + cancellationToken: ct); +``` + +Captured messages preserve the original payload and headers, then add operational headers such as `dead-letter-id`, `dead-letter-channel`, `dead-letter-reason`, `dead-letter-attempts`, and `dead-letter-source`. + +## Replay Handoff + +Replay is explicit. `PrepareReplayAsync` loads the captured message and adds replay metadata without deleting the dead-letter record: + +```csharp +var replay = await channel.PrepareReplayAsync(deadLetter.Id, ct); + +if (replay.ReadyForReplay) +{ + await fulfillmentInbox.ProcessAsync(replay.Message!, cancellationToken: ct); +} +``` + +Use a durable `IDeadLetterStore` implementation for production transport boundaries. `InMemoryDeadLetterStore` is intended for tests, samples, and embedded in-process usage. + +## Source Generator + +Use `[GenerateDeadLetterChannel]` when the channel name, source, and store factory are stable at compile time. See [Dead Letter Channel Generator](../../generators/dead-letter-channel.md). + +## Example + +The production-shaped fulfillment example demonstrates the fluent channel, generated channel, and `IServiceCollection` integration: + +- `src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs` diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 57da1d82..a3e927b3 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -301,6 +301,8 @@ href: messaging/message-translator.md - name: Claim Check href: messaging/claim-check.md + - name: Dead Letter Channel + href: messaging/dead-letter-channel.md - name: Enterprise Message Routing href: messaging/message-routing.md - name: Routing Slip diff --git a/src/PatternKit.Core/Messaging/Reliability/DeadLetterChannel.cs b/src/PatternKit.Core/Messaging/Reliability/DeadLetterChannel.cs new file mode 100644 index 00000000..e4bd8364 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Reliability/DeadLetterChannel.cs @@ -0,0 +1,343 @@ +namespace PatternKit.Messaging.Reliability; + +/// +/// Captures failed or undeliverable messages with operational metadata and replay handoff support. +/// +public sealed class DeadLetterChannel +{ + private readonly string _name; + private readonly string? _source; + private readonly IDeadLetterStore _store; + private readonly DeadLetterIdFactory _idFactory; + private readonly Func _clock; + private readonly bool _includeExceptionDetails; + + private DeadLetterChannel( + string name, + string? source, + IDeadLetterStore store, + DeadLetterIdFactory idFactory, + Func clock, + bool includeExceptionDetails) + { + _name = name; + _source = source; + _store = store; + _idFactory = idFactory; + _clock = clock; + _includeExceptionDetails = includeExceptionDetails; + } + + /// Creates a dead-letter channel builder. + public static Builder Create(string name = "dead-letter-channel") + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Dead-letter channel name cannot be null, empty, or whitespace.", nameof(name)); + + return new Builder(name); + } + + /// Captures a failed message using a synchronous store call. + public DeadLetterMessage Capture( + Message message, + string reason, + Exception? exception = null, + int attempts = 0, + MessageContext? context = null) + => CaptureAsync(message, reason, exception, attempts, context).AsTask().GetAwaiter().GetResult(); + + /// Captures a failed message with reason, attempts, original headers, and replay metadata. + public async ValueTask> CaptureAsync( + Message message, + string reason, + Exception? exception = null, + int attempts = 0, + MessageContext? context = null, + CancellationToken cancellationToken = default) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Dead-letter reason cannot be null, empty, or whitespace.", nameof(reason)); + if (attempts < 0) + throw new ArgumentOutOfRangeException(nameof(attempts), attempts, "Attempt count cannot be negative."); + + context ??= MessageContext.From(message, cancellationToken); + var id = _idFactory(message, reason, context); + if (string.IsNullOrWhiteSpace(id)) + throw new InvalidOperationException("Dead-letter id factory returned null, empty, or whitespace."); + + var headers = message.Headers + .With("dead-letter-id", id) + .With("dead-letter-channel", _name) + .With("dead-letter-reason", reason) + .With("dead-letter-attempts", attempts) + .With("dead-letter-failed-at", _clock()); + + if (!string.IsNullOrWhiteSpace(_source)) + headers = headers.With("dead-letter-source", _source); + + var deadLetter = new DeadLetterMessage( + id, + message.WithHeaders(headers), + reason, + headers.TryGetDateTimeOffset("dead-letter-failed-at", out var failedAt) ? failedAt : _clock(), + attempts, + _source, + _includeExceptionDetails ? exception?.GetType().FullName : null, + _includeExceptionDetails ? exception?.Message : null); + + await _store.EnqueueAsync(deadLetter, cancellationToken).ConfigureAwait(false); + return deadLetter; + } + + /// Attempts to load a dead-lettered message for replay handoff. + public ValueTask> PrepareReplayAsync( + string deadLetterId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(deadLetterId)) + throw new ArgumentException("Dead-letter id cannot be null, empty, or whitespace.", nameof(deadLetterId)); + + return PrepareReplayCoreAsync(deadLetterId, cancellationToken); + } + + private async ValueTask> PrepareReplayCoreAsync( + string deadLetterId, + CancellationToken cancellationToken) + { + var deadLetter = await _store.TryLoadAsync(deadLetterId, cancellationToken).ConfigureAwait(false); + if (deadLetter is null) + return DeadLetterReplayResult.Miss(deadLetterId, "Dead-letter message was not found."); + + var replayMessage = deadLetter.Message.Enrich(headers => headers + .With("dead-letter-replay-id", Guid.NewGuid().ToString("N")) + .With("dead-letter-replayed-from", deadLetter.Id) + .With("dead-letter-replayed-at", _clock())); + + return DeadLetterReplayResult.Ready(deadLetter, replayMessage); + } + + /// Dead-letter channel fluent builder. + public sealed class Builder + { + private readonly string _name; + private string? _source; + private IDeadLetterStore? _store; + private DeadLetterIdFactory? _idFactory; + private Func _clock = () => DateTimeOffset.UtcNow; + private bool _includeExceptionDetails = true; + + internal Builder(string name) + { + _name = name; + } + + /// Sets the pipeline, endpoint, or transport source that produced failures. + public Builder FromSource(string source) + { + if (string.IsNullOrWhiteSpace(source)) + throw new ArgumentException("Dead-letter source cannot be null, empty, or whitespace.", nameof(source)); + + _source = source; + return this; + } + + /// Uses a custom durable dead-letter store. + public Builder UseStore(IDeadLetterStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + return this; + } + + /// Uses a custom dead-letter id factory. + public Builder UseIds(DeadLetterIdFactory idFactory) + { + _idFactory = idFactory ?? throw new ArgumentNullException(nameof(idFactory)); + return this; + } + + /// Uses a deterministic clock for tests or persistence coordination. + public Builder UseClock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + /// Controls whether captured exception type and message are persisted. + public Builder IncludeExceptionDetails(bool include = true) + { + _includeExceptionDetails = include; + return this; + } + + /// Builds the dead-letter channel. + public DeadLetterChannel Build() + => new( + _name, + _source, + _store ?? new InMemoryDeadLetterStore(), + _idFactory ?? DefaultIdFactory, + _clock, + _includeExceptionDetails); + + private static string DefaultIdFactory(Message message, string reason, MessageContext context) + => message.Headers.MessageId is { Length: > 0 } messageId + ? $"dead:{messageId}" + : $"dead:{Guid.NewGuid():N}"; + } +} + +/// Creates a dead-letter id from the failed message and failure reason. +public delegate string DeadLetterIdFactory( + Message message, + string reason, + MessageContext context); + +/// Store abstraction for durable dead-letter records. +public interface IDeadLetterStore +{ + /// Persists a dead-letter message. + ValueTask EnqueueAsync(DeadLetterMessage message, CancellationToken cancellationToken = default); + + /// Attempts to load a dead-letter message by id. + ValueTask?> TryLoadAsync(string id, CancellationToken cancellationToken = default); +} + +/// In-memory dead-letter store suitable for tests, samples, and embedded applications. +public sealed class InMemoryDeadLetterStore : IDeadLetterStore +{ + private readonly List> _messages = new(); + + /// Captured dead-letter messages. + public IReadOnlyList> Messages => _messages; + + /// + public ValueTask EnqueueAsync(DeadLetterMessage message, CancellationToken cancellationToken = default) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + + cancellationToken.ThrowIfCancellationRequested(); + _messages.Add(message); + return default; + } + + /// + public ValueTask?> TryLoadAsync(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Dead-letter id cannot be null, empty, or whitespace.", nameof(id)); + + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask?>(_messages.LastOrDefault(message => + string.Equals(message.Id, id, StringComparison.Ordinal))); + } +} + +/// Captured failed message with operational failure metadata. +public sealed class DeadLetterMessage +{ + /// Creates a dead-letter message. + public DeadLetterMessage( + string id, + Message message, + string reason, + DateTimeOffset failedAt, + int attempts, + string? source = null, + string? exceptionType = null, + string? exceptionMessage = null) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Dead-letter id cannot be null, empty, or whitespace.", nameof(id)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Dead-letter reason cannot be null, empty, or whitespace.", nameof(reason)); + if (attempts < 0) + throw new ArgumentOutOfRangeException(nameof(attempts), attempts, "Attempt count cannot be negative."); + + Id = id; + Message = message ?? throw new ArgumentNullException(nameof(message)); + Reason = reason; + FailedAt = failedAt; + Attempts = attempts; + Source = source; + ExceptionType = exceptionType; + ExceptionMessage = exceptionMessage; + } + + /// Dead-letter record identifier. + public string Id { get; } + + /// The original message with dead-letter metadata headers attached. + public Message Message { get; } + + /// Operational failure reason. + public string Reason { get; } + + /// When the message failed. + public DateTimeOffset FailedAt { get; } + + /// Number of attempts before dead-lettering. + public int Attempts { get; } + + /// Pipeline, endpoint, or transport source that captured the failure. + public string? Source { get; } + + /// Captured exception type when exception details are enabled. + public string? ExceptionType { get; } + + /// Captured exception message when exception details are enabled. + public string? ExceptionMessage { get; } +} + +/// Replay handoff result for a dead-lettered message. +public sealed class DeadLetterReplayResult +{ + private DeadLetterReplayResult( + DeadLetterMessage? deadLetter, + Message? message, + bool found, + string? missingReason) + { + DeadLetter = deadLetter; + Message = message; + Found = found; + MissingReason = missingReason; + } + + /// The loaded dead-letter record when found. + public DeadLetterMessage? DeadLetter { get; } + + /// The message prepared for replay when found. + public Message? Message { get; } + + /// Gets whether the dead-letter record was found. + public bool Found { get; } + + /// Gets whether the message is ready for replay. + public bool ReadyForReplay => Found && Message is not null; + + /// Reason the dead-letter record could not be loaded. + public string? MissingReason { get; } + + /// Creates a successful replay result. + public static DeadLetterReplayResult Ready( + DeadLetterMessage deadLetter, + Message message) + => new(deadLetter ?? throw new ArgumentNullException(nameof(deadLetter)), + message ?? throw new ArgumentNullException(nameof(message)), + true, + null); + + /// Creates a missing replay result. + public static DeadLetterReplayResult Miss(string id, string reason) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Dead-letter id cannot be null, empty, or whitespace.", nameof(id)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Missing reason cannot be null, empty, or whitespace.", nameof(reason)); + + return new(null, null, false, reason); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 51899c58..a0c8c73e 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -107,6 +107,7 @@ public sealed record MessageRouterVisitorExample(Func Run); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); +public sealed record GeneratedDeadLetterChannelExample(FulfillmentDeadLetterChannelExampleRunner Runner, FulfillmentDeadLetterWorkflow Workflow); public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); public sealed record GeneratedSplitterAggregatorExample(MessageRoutingExampleRunner Runner); public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); @@ -162,6 +163,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddGeneratedMessageEnvelopeExample() .AddGeneratedMessageTranslatorExample() .AddGeneratedClaimCheckExample() + .AddGeneratedDeadLetterChannelExample() .AddGeneratedRecipientListExample() .AddGeneratedSplitterAggregatorExample() .AddPatternsShowcaseExample() @@ -411,6 +413,15 @@ public static IServiceCollection AddGeneratedClaimCheckExample(this IServiceColl return services.RegisterExample("Generated Claim Check", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddGeneratedDeadLetterChannelExample(this IServiceCollection services) + { + services.AddFulfillmentDeadLetterChannelExample(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Generated Dead Letter Channel", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddGeneratedRecipientListExample(this IServiceCollection services) { services.AddRecipientListGeneratorExample(); diff --git a/src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs b/src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs new file mode 100644 index 00000000..df034d03 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Reliability; + +namespace PatternKit.Examples.Messaging; + +/// +/// Demonstrates fluent and source-generated dead-letter channels for failed fulfillment messages. +/// +public static class FulfillmentDeadLetterChannelExample +{ + /// Runs the fluent dead-letter path. + public static FulfillmentDeadLetterSummary RunFluent() + { + var store = new InMemoryDeadLetterStore(); + var channel = FulfillmentDeadLetterPolicies.CreateFluentChannel(store); + return CaptureAndReplay(channel, CreateCommand("order-100"), "carrier timeout"); + } + + /// Runs the source-generated dead-letter path. + public static FulfillmentDeadLetterSummary RunGenerated() + => CaptureAndReplay( + GeneratedFulfillmentDeadLetters.CreateChannel(), + CreateCommand("order-200"), + "warehouse rejected request"); + + /// Creates a production-shaped fulfillment command envelope. + public static Message CreateCommand(string orderId) + => Message.Create(new FulfillmentCommand(orderId, "warehouse-east")) + .WithMessageId("fulfillment:" + orderId) + .WithCorrelationId("checkout:" + orderId) + .WithContentType("application/vnd.patternkit.fulfillment+json"); + + internal static FulfillmentDeadLetterSummary CaptureAndReplay( + DeadLetterChannel channel, + Message command, + string reason) + { + var captured = channel.Capture(command, reason, new TimeoutException(reason), attempts: 4); + var replay = channel.PrepareReplayAsync(captured.Id).AsTask().GetAwaiter().GetResult(); + + return new FulfillmentDeadLetterSummary( + captured.Id, + captured.Reason, + captured.Attempts, + captured.Message.Headers.CorrelationId ?? string.Empty, + replay.ReadyForReplay, + replay.Message?.Headers.GetString("dead-letter-replayed-from") ?? string.Empty); + } +} + +/// Fulfillment command that would normally be delivered to a warehouse or carrier boundary. +public sealed record FulfillmentCommand(string OrderId, string Warehouse); + +/// Summary returned by the dead-letter channel example. +public sealed record FulfillmentDeadLetterSummary( + string DeadLetterId, + string Reason, + int Attempts, + string CorrelationId, + bool ReadyForReplay, + string ReplayedFrom); + +/// Fluent dead-letter channel policy helpers. +public static class FulfillmentDeadLetterPolicies +{ + public static DeadLetterChannel CreateFluentChannel( + IDeadLetterStore store) + => DeadLetterChannel.Create("fulfillment-dead-letter") + .FromSource("checkout.fulfillment") + .UseStore(store) + .UseIds(static (message, _, _) => "fulfillment-dead:" + (message.Headers.MessageId ?? message.Payload.OrderId)) + .Build(); +} + +/// DI-friendly service that imports the dead-letter workflow into an application. +public sealed class FulfillmentDeadLetterWorkflow +{ + private readonly DeadLetterChannel _channel; + + public FulfillmentDeadLetterWorkflow(DeadLetterChannel channel) + { + _channel = channel; + } + + public FulfillmentDeadLetterSummary Capture(Message command, string reason) + => FulfillmentDeadLetterChannelExample.CaptureAndReplay(_channel, command, reason); +} + +/// Runner exposing fluent and generated dead-letter channel paths. +public sealed record FulfillmentDeadLetterChannelExampleRunner( + Func RunFluent, + Func RunGenerated); + +/// Dependency injection extensions for the dead-letter channel example. +public static class FulfillmentDeadLetterChannelServiceCollectionExtensions +{ + public static IServiceCollection AddFulfillmentDeadLetterChannelExample(this IServiceCollection services) + { + services.AddSingleton, InMemoryDeadLetterStore>(); + services.AddSingleton(static sp => FulfillmentDeadLetterPolicies.CreateFluentChannel( + sp.GetRequiredService>())); + services.AddSingleton(); + services.AddSingleton(new FulfillmentDeadLetterChannelExampleRunner( + FulfillmentDeadLetterChannelExample.RunFluent, + FulfillmentDeadLetterChannelExample.RunGenerated)); + return services; + } +} + +/// Source-generated dead-letter channel used by the production-shaped example. +[GenerateDeadLetterChannel( + typeof(FulfillmentCommand), + FactoryName = "CreateChannel", + ChannelName = "fulfillment-dead-letter", + Source = "checkout.fulfillment", + IdPrefix = "fulfillment-dead")] +public static partial class GeneratedFulfillmentDeadLetters +{ + [DeadLetterStoreFactory] + private static IDeadLetterStore CreateStore() + => new InMemoryDeadLetterStore(); +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index e2652c11..ec9c89b5 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -256,6 +256,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["ClaimCheck"], ["external payload storage", "source-generated claim check", "DI composition"]), + Descriptor( + "Generated Dead Letter Channel", + "src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs", + "test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs", + "docs/examples/generated-dead-letter-channel.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["DeadLetterChannel"], + ["failed message capture", "source-generated dead-letter channel", "DI composition"]), Descriptor( "Generated Recipient List", "src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index fd3111d9..1beaa313 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -402,6 +402,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/LargeDocumentClaimCheckExampleTests.cs", ["fluent payload check-in/restore", "generated claim check factory", "DI-importable large document workflow example"]), + Pattern("Dead Letter Channel", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/dead-letter-channel.md", + "src/PatternKit.Core/Messaging/Reliability/DeadLetterChannel.cs", + "test/PatternKit.Tests/Messaging/Reliability/DeadLetterChannelTests.cs", + "docs/generators/dead-letter-channel.md", + "src/PatternKit.Generators/Messaging/DeadLetterChannelGenerator.cs", + "test/PatternKit.Generators.Tests/DeadLetterChannelGeneratorTests.cs", + null, + "docs/examples/generated-dead-letter-channel.md", + "src/PatternKit.Examples/Messaging/FulfillmentDeadLetterChannelExample.cs", + "test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs", + ["fluent failure channel", "generated dead-letter channel factory", "DI-importable fulfillment failure workflow example"]), + Pattern("Content-Based Router", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-routing.md", "src/PatternKit.Core/Messaging/Routing/ContentRouter.cs", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/DeadLetterChannelAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/DeadLetterChannelAttributes.cs new file mode 100644 index 00000000..87d3aaee --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/DeadLetterChannelAttributes.cs @@ -0,0 +1,34 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates a dead-letter channel factory for failed or undeliverable messages. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateDeadLetterChannelAttribute(Type payloadType) : Attribute +{ + /// Payload type captured by the generated dead-letter channel. + public Type PayloadType { get; } = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + + /// Name of the generated factory method. + public string FactoryName { get; set; } = "Create"; + + /// Operational channel name recorded in dead-letter headers. + public string ChannelName { get; set; } = "dead-letter-channel"; + + /// Source endpoint, pipeline, or transport name recorded in dead-letter metadata. + public string Source { get; set; } = "application"; + + /// Prefix used by generated dead-letter identifiers. + public string IdPrefix { get; set; } = "dead"; + + /// Controls whether exception type and message are persisted. + public bool IncludeExceptionDetails { get; set; } = true; +} + +/// +/// Marks the store factory used by a generated dead-letter channel. +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class DeadLetterStoreFactoryAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 507224d2..6c0fc32d 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -177,6 +177,9 @@ PKCR005 | PatternKit.Generators.Messaging | Error | Content router route name or PKCC001 | PatternKit.Generators.Messaging | Error | Claim check host must be partial. PKCC002 | PatternKit.Generators.Messaging | Error | Claim check must declare exactly one store factory. PKCC003 | PatternKit.Generators.Messaging | Error | Claim check store factory signature is invalid. +PKDL001 | PatternKit.Generators.Messaging | Error | Dead-letter channel host must be partial. +PKDL002 | PatternKit.Generators.Messaging | Error | Dead-letter channel must declare exactly one store factory. +PKDL003 | PatternKit.Generators.Messaging | Error | Dead-letter store factory signature is invalid. PKME001 | PatternKit.Generators.Messaging | Error | Message envelope type must be partial. PKME002 | PatternKit.Generators.Messaging | Error | Message envelope must declare at least one required header. PKME003 | PatternKit.Generators.Messaging | Error | Message envelope header configuration is invalid. diff --git a/src/PatternKit.Generators/Messaging/DeadLetterChannelGenerator.cs b/src/PatternKit.Generators/Messaging/DeadLetterChannelGenerator.cs new file mode 100644 index 00000000..efa97cb7 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/DeadLetterChannelGenerator.cs @@ -0,0 +1,172 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class DeadLetterChannelGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.Messaging.GenerateDeadLetterChannelAttribute"; + private const string StoreFactoryAttributeName = "PatternKit.Generators.Messaging.DeadLetterStoreFactoryAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKDL001", + "Dead-letter channel host must be partial", + "Type '{0}' is marked with [GenerateDeadLetterChannel] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingStoreFactory = new( + "PKDL002", + "Dead-letter store factory is missing", + "Dead-letter channel '{0}' must declare exactly one [DeadLetterStoreFactory] method", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidStoreFactory = new( + "PKDL003", + "Dead-letter store factory signature is invalid", + "Dead-letter store factory '{0}' must be static, parameterless, and return IDeadLetterStore", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + 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(static a => a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + 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 = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (payloadType is null) + return; + + var factories = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == StoreFactoryAttributeName)) + .ToArray(); + if (factories.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingStoreFactory, node.Identifier.GetLocation(), type.Name)); + return; + } + + var factory = factories[0]; + if (!IsStoreFactory(factory, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStoreFactory, factory.Locations.FirstOrDefault(), factory.Name)); + return; + } + + context.AddSource($"{type.Name}.DeadLetterChannel.g.cs", SourceText.From( + GenerateSource( + type, + payloadType, + factory.Name, + GetNamedString(attribute, "FactoryName") ?? "Create", + GetNamedString(attribute, "ChannelName") ?? "dead-letter-channel", + GetNamedString(attribute, "Source") ?? "application", + GetNamedString(attribute, "IdPrefix") ?? "dead", + GetNamedBool(attribute, "IncludeExceptionDetails") ?? true), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + string storeFactory, + string factoryName, + string channelName, + string source, + string idPrefix, + bool includeExceptionDetails) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var payloadName = payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + 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.DeadLetterChannel<") + .Append(payloadName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Reliability.DeadLetterChannel<") + .Append(payloadName).Append(">.Create(\"").Append(Escape(channelName)).AppendLine("\")"); + sb.Append(" .FromSource(\"").Append(Escape(source)).AppendLine("\")"); + sb.Append(" .UseStore(").Append(storeFactory).AppendLine("())"); + sb.Append(" .UseIds(static (message, _, _) => \"") + .Append(Escape(idPrefix)) + .Append(":\" + (message.Headers.MessageId ?? global::System.Guid.NewGuid().ToString(\"N\")))") + .AppendLine(); + sb.Append(" .IncludeExceptionDetails(").Append(includeExceptionDetails ? "true" : "false").AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsStoreFactory(IMethodSymbol method, ITypeSymbol payloadType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 0 + && method.ReturnType is INamedTypeSymbol named + && named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Reliability.IDeadLetterStore" + && SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static bool? GetNamedBool(AttributeData attribute, string name) + { + var value = attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value; + return value.Value is bool boolean ? boolean : null; + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 668ecbb5..691f5b05 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -92,6 +92,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var generatedRecipients = provider.GetRequiredService(); var generatedTranslator = provider.GetRequiredService(); var generatedClaimCheck = provider.GetRequiredService(); + var generatedDeadLetters = provider.GetRequiredService(); var envelope = provider.GetRequiredService(); var cqrs = provider.GetRequiredService(); var checkout = provider.GetRequiredService(); @@ -158,6 +159,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("message router visitor aggregates totals", routing.Run().AggregatedTotal == 100m), ("generated message translator normalizes partner events", generatedTranslator.Service.Import(PartnerEventTranslatorExample.CreatePartnerMessage("partner-a", "EXT-100", 125m)).Accepted), ("generated claim check restores large document payloads", generatedClaimCheck.Workflow.Process(LargeDocumentClaimCheckExample.CreateDocumentMessage("doc-100")).Restored), + ("generated dead-letter channel prepares replay handoff", generatedDeadLetters.Workflow.Capture(FulfillmentDeadLetterChannelExample.CreateCommand("order-100"), "adapter failed").ReadyForReplay), ("generated recipient list delivers billing and audit recipients", generatedRecipientList.DeliveredRecipients.Count == 2), ("message envelope example tracks first attempt", envelope.Run().Attempt == 1), ("CQRS fluent path matches command writes to query reads", cqrsFluent.QueryMatchedCommand), diff --git a/test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs new file mode 100644 index 00000000..93a231f7 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/FulfillmentDeadLetterChannelExampleTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.Messaging; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.Messaging; + +[Feature("Fulfillment dead-letter channel example")] +public sealed class FulfillmentDeadLetterChannelExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent dead letter channel captures and prepares replay")] + [Fact] + public Task Fluent_Dead_Letter_Channel_Captures_And_Prepares_Replay() + => Given("the fluent fulfillment dead-letter channel example", FulfillmentDeadLetterChannelExample.RunFluent) + .Then("the failed message is captured with replay metadata", summary => + { + ScenarioExpect.Equal("fulfillment-dead:fulfillment:order-100", summary.DeadLetterId); + ScenarioExpect.Equal("carrier timeout", summary.Reason); + ScenarioExpect.Equal(4, summary.Attempts); + ScenarioExpect.Equal("checkout:order-100", summary.CorrelationId); + ScenarioExpect.True(summary.ReadyForReplay); + ScenarioExpect.Equal(summary.DeadLetterId, summary.ReplayedFrom); + }) + .AssertPassed(); + + [Scenario("Generated dead letter channel captures and prepares replay")] + [Fact] + public Task Generated_Dead_Letter_Channel_Captures_And_Prepares_Replay() + => Given("the generated fulfillment dead-letter channel example", FulfillmentDeadLetterChannelExample.RunGenerated) + .Then("the failed message is captured with generated channel metadata", summary => + { + ScenarioExpect.Equal("fulfillment-dead:fulfillment:order-200", summary.DeadLetterId); + ScenarioExpect.Equal("warehouse rejected request", summary.Reason); + ScenarioExpect.True(summary.ReadyForReplay); + ScenarioExpect.Equal(summary.DeadLetterId, summary.ReplayedFrom); + }) + .AssertPassed(); + + [Scenario("Dead letter channel example is importable through IServiceCollection")] + [Fact] + public Task Dead_Letter_Channel_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection importing the dead-letter channel example", () => + { + var services = new ServiceCollection(); + services.AddFulfillmentDeadLetterChannelExample(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the importing application captures a failed fulfillment command", provider => + { + using (provider) + { + var workflow = provider.GetRequiredService(); + return workflow.Capture( + FulfillmentDeadLetterChannelExample.CreateCommand("order-300"), + "fulfillment adapter failed"); + } + }) + .Then("the registered workflow preserves headers and replay handoff data", summary => + { + ScenarioExpect.Equal("fulfillment-dead:fulfillment:order-300", summary.DeadLetterId); + ScenarioExpect.Equal("checkout:order-300", summary.CorrelationId); + ScenarioExpect.True(summary.ReadyForReplay); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 3151e67f..641c61bb 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -41,6 +41,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Message Envelope", "Message Translator", "Claim Check", + "Dead Letter Channel", "Content-Based Router", "Recipient List", "Splitter", @@ -101,7 +102,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(EnterprisePatternAdditions.OrderBy(static x => x), patterns.Select(static p => p.Name).OrderBy(static x => x))) .And("enterprise entries are grouped by integration reliability and architecture families", patterns => { - ScenarioExpect.Equal(12, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(13, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index b8303354..7046bc86 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -110,6 +110,8 @@ private enum TestTrigger { typeof(ContentRouteDefaultAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateClaimCheckAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ClaimCheckStoreFactoryAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateDeadLetterChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(DeadLetterStoreFactoryAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateSplitterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(SplitterProjectionAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateAggregatorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -594,6 +596,14 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf StoreName = "blob-store", ClaimIdPrefix = "doc" }; + var deadLetter = new GenerateDeadLetterChannelAttribute(typeof(string)) + { + FactoryName = "BuildDeadLetters", + ChannelName = "checkout-dead", + Source = "checkout.fulfillment", + IdPrefix = "checkout", + IncludeExceptionDetails = false + }; var recipientList = new GenerateRecipientListAttribute(typeof(string)) { FactoryName = "BuildRecipients", @@ -703,6 +713,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("documents", claimCheck.ClaimCheckName); ScenarioExpect.Equal("blob-store", claimCheck.StoreName); ScenarioExpect.Equal("doc", claimCheck.ClaimIdPrefix); + ScenarioExpect.Equal(typeof(string), deadLetter.PayloadType); + ScenarioExpect.Equal("BuildDeadLetters", deadLetter.FactoryName); + ScenarioExpect.Equal("checkout-dead", deadLetter.ChannelName); + ScenarioExpect.Equal("checkout.fulfillment", deadLetter.Source); + ScenarioExpect.Equal("checkout", deadLetter.IdPrefix); + ScenarioExpect.False(deadLetter.IncludeExceptionDetails); ScenarioExpect.Equal(typeof(string), recipientList.PayloadType); ScenarioExpect.Equal("BuildRecipients", recipientList.FactoryName); ScenarioExpect.Equal("BuildRecipientsAsync", recipientList.AsyncFactoryName); @@ -766,6 +782,8 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new ContentRouteAttribute("name", 1, "")); ScenarioExpect.Throws(() => new GenerateClaimCheckAttribute(null!)); ScenarioExpect.IsType(new ClaimCheckStoreFactoryAttribute()); + ScenarioExpect.Throws(() => new GenerateDeadLetterChannelAttribute(null!)); + ScenarioExpect.IsType(new DeadLetterStoreFactoryAttribute()); ScenarioExpect.Throws(() => new GenerateRecipientListAttribute(null!)); ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("", 1, "Predicate")); ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("name", 1, "")); diff --git a/test/PatternKit.Generators.Tests/DeadLetterChannelGeneratorTests.cs b/test/PatternKit.Generators.Tests/DeadLetterChannelGeneratorTests.cs new file mode 100644 index 00000000..36700132 --- /dev/null +++ b/test/PatternKit.Generators.Tests/DeadLetterChannelGeneratorTests.cs @@ -0,0 +1,138 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging.Reliability; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class DeadLetterChannelGeneratorTests +{ + [Scenario("Generates dead letter channel factory")] + [Fact] + public void GeneratesDeadLetterChannelFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging.Reliability; + + namespace MyApp; + + public sealed record Order(string Id); + + [GenerateDeadLetterChannel( + typeof(Order), + FactoryName = "BuildChannel", + ChannelName = "checkout-dead", + Source = "checkout.fulfillment", + IdPrefix = "checkout-dead", + IncludeExceptionDetails = false)] + public static partial class CheckoutDeadLetters + { + [DeadLetterStoreFactory] + private static IDeadLetterStore CreateStore() => new InMemoryDeadLetterStore(); + } + + public static class Demo + { + public static DeadLetterChannel Run() => CheckoutDeadLetters.BuildChannel(); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesDeadLetterChannelFactory)); + var gen = new DeadLetterChannelGenerator(); + _ = 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("CheckoutDeadLetters.DeadLetterChannel.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("BuildChannel", text); + ScenarioExpect.Contains(".FromSource(\"checkout.fulfillment\")", text); + ScenarioExpect.Contains(".UseStore(CreateStore())", text); + ScenarioExpect.Contains("\"checkout-dead:\"", text); + ScenarioExpect.Contains(".IncludeExceptionDetails(false)", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non partial dead letter channel")] + [Fact] + public void ReportsDiagnosticForNonPartialDeadLetterChannel() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order(string Id); + + [GenerateDeadLetterChannel(typeof(Order))] + public static class CheckoutDeadLetters; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialDeadLetterChannel)); + var gen = new DeadLetterChannelGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKDL001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing dead letter store factory")] + [Fact] + public void ReportsDiagnosticForMissingDeadLetterStoreFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order(string Id); + + [GenerateDeadLetterChannel(typeof(Order))] + public static partial class CheckoutDeadLetters; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingDeadLetterStoreFactory)); + var gen = new DeadLetterChannelGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKDL002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid dead letter store factory")] + [Fact] + public void ReportsDiagnosticForInvalidDeadLetterStoreFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order(string Id); + + [GenerateDeadLetterChannel(typeof(Order))] + public static partial class CheckoutDeadLetters + { + [DeadLetterStoreFactory] + private static string CreateStore() => "bad"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidDeadLetterStoreFactory)); + var gen = new DeadLetterChannelGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKDL003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(DeadLetterChannel<>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Reliability/DeadLetterChannelTests.cs b/test/PatternKit.Tests/Messaging/Reliability/DeadLetterChannelTests.cs new file mode 100644 index 00000000..5ffd0f97 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Reliability/DeadLetterChannelTests.cs @@ -0,0 +1,106 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Reliability; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Reliability; + +public sealed class DeadLetterChannelTests +{ + [Scenario("CaptureAsync StoresFailedMessageWithReasonAttemptsAndHeaders")] + [Fact] + public async Task CaptureAsync_StoresFailedMessageWithReasonAttemptsAndHeaders() + { + var store = new InMemoryDeadLetterStore(); + var failedAt = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero); + var channel = DeadLetterChannel.Create("checkout-dead") + .FromSource("checkout.fulfillment") + .UseStore(store) + .UseClock(() => failedAt) + .Build(); + + var deadLetter = await channel.CaptureAsync( + Message.Create(new Order("order-1")).WithMessageId("msg-1").WithCorrelationId("corr-1"), + "fulfillment unavailable", + new InvalidOperationException("gateway down"), + attempts: 3); + + ScenarioExpect.Equal("dead:msg-1", deadLetter.Id); + ScenarioExpect.Equal("fulfillment unavailable", deadLetter.Reason); + ScenarioExpect.Equal(3, deadLetter.Attempts); + ScenarioExpect.Equal(failedAt, deadLetter.FailedAt); + ScenarioExpect.Equal("checkout.fulfillment", deadLetter.Source); + ScenarioExpect.Equal(typeof(InvalidOperationException).FullName, deadLetter.ExceptionType); + ScenarioExpect.Equal("gateway down", deadLetter.ExceptionMessage); + ScenarioExpect.Equal("corr-1", deadLetter.Message.Headers.CorrelationId); + ScenarioExpect.Equal("checkout-dead", deadLetter.Message.Headers.GetString("dead-letter-channel")); + ScenarioExpect.Single(store.Messages); + } + + [Scenario("PrepareReplayAsync ReturnsReplayMessageWithReplayMetadata")] + [Fact] + public async Task PrepareReplayAsync_ReturnsReplayMessageWithReplayMetadata() + { + var store = new InMemoryDeadLetterStore(); + var channel = DeadLetterChannel.Create() + .UseStore(store) + .UseIds(static (message, _, _) => "dlq:" + message.Payload.Id) + .UseClock(() => new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero)) + .Build(); + var deadLetter = await channel.CaptureAsync(Message.Create(new Order("order-1")), "poison message"); + + var replay = await channel.PrepareReplayAsync(deadLetter.Id); + + ScenarioExpect.True(replay.Found); + ScenarioExpect.True(replay.ReadyForReplay); + ScenarioExpect.Equal(deadLetter, replay.DeadLetter); + ScenarioExpect.Equal("order-1", replay.Message!.Payload.Id); + ScenarioExpect.Equal("dlq:order-1", replay.Message.Headers.GetString("dead-letter-replayed-from")); + ScenarioExpect.NotNull(replay.Message.Headers.GetString("dead-letter-replay-id")); + } + + [Scenario("PrepareReplayAsync ReturnsMissForUnknownDeadLetter")] + [Fact] + public async Task PrepareReplayAsync_ReturnsMissForUnknownDeadLetter() + { + var channel = DeadLetterChannel.Create().Build(); + + var replay = await channel.PrepareReplayAsync("missing"); + + ScenarioExpect.False(replay.Found); + ScenarioExpect.False(replay.ReadyForReplay); + ScenarioExpect.Equal("Dead-letter message was not found.", replay.MissingReason); + } + + [Scenario("DeadLetterChannel ValidatesConfigurationAndInputs")] + [Fact] + public async Task DeadLetterChannel_ValidatesConfigurationAndInputs() + { + ScenarioExpect.Throws(() => DeadLetterChannel.Create("")); + ScenarioExpect.Throws(() => DeadLetterChannel.Create().FromSource(" ")); + ScenarioExpect.Throws(() => DeadLetterChannel.Create().UseStore(null!)); + ScenarioExpect.Throws(() => DeadLetterChannel.Create().UseIds(null!)); + ScenarioExpect.Throws(() => DeadLetterChannel.Create().UseClock(null!)); + + var channel = DeadLetterChannel.Create().Build(); + await ScenarioExpect.ThrowsAsync(async () => await channel.CaptureAsync(null!, "failed")); + await ScenarioExpect.ThrowsAsync(async () => await channel.CaptureAsync(Message.Create(new Order("1")), "")); + await ScenarioExpect.ThrowsAsync(async () => await channel.CaptureAsync(Message.Create(new Order("1")), "failed", attempts: -1)); + await ScenarioExpect.ThrowsAsync(async () => await channel.PrepareReplayAsync("")); + } + + [Scenario("InMemoryDeadLetterStore ObservesCancellationAndValidation")] + [Fact] + public async Task InMemoryDeadLetterStore_ObservesCancellationAndValidation() + { + var store = new InMemoryDeadLetterStore(); + using var source = new CancellationTokenSource(); + source.Cancel(); + + await ScenarioExpect.ThrowsAsync(async () => await store.EnqueueAsync(null!)); + await ScenarioExpect.ThrowsAsync(async () => + await store.EnqueueAsync(new DeadLetterMessage("id", Message.Create(new Order("1")), "failed", DateTimeOffset.UtcNow, 0), source.Token)); + await ScenarioExpect.ThrowsAsync(async () => await store.TryLoadAsync("")); + } + + private sealed record Order(string Id); +}