diff --git a/docs/tutorials/idempotency.md b/docs/tutorials/idempotency.md index 87ffab9b3..a042ffab4 100644 --- a/docs/tutorials/idempotency.md +++ b/docs/tutorials/idempotency.md @@ -136,6 +136,46 @@ The idempotency check and the process of marking an incoming envelope are themse to avoid Wolverine from making unnecessary database calls. ~~~~ ::: +## Idempotency on Non Transactional Handlers + +::: tip +Idempotency checks are automatic for any message handler that uses any kind of +transactional middleware. +::: + +::: warning +This functionality does require some kind of message persistence to be configured for your application as it utilizes +Wolverine's inbox functionality +::: + +Every usage you've seen so far has featured utilizing Wolverine's transactional middleware support on handlers that +use [EF Core](/guide/durability/efcore/transactional-middleware) or [Marten](/guide/durability/marten/transactional-middleware). + +But of course, you may have message handlers that don't need to touch your underlying storage at all. For example, a message +handler might do nothing but call an external web service. You may want to make this message handler be idempotent to protect +against duplicated calls to that web service. You're in luck, because Wolverine exposes this policy to do exactly that: + +snippet: sample_using_AutoApplyIdempotencyOnNonTransactionalHandlers + +Specifically, see the call to `WolverineOptions.Policies.AutoApplyIdempotencyOnNonTransactionalHandlers()` above. What that +is doing is: + +1. Inserting a call to assert that the current message doesn't already exist in your applications default envelope storage by + the Wolverine message id. If the message is already marked as `Handled` in the inbox, Wolverine will reject and discard the current + message processing +2. Assuming the message is all new, Wolverine will try to persist the `Handled` state in the default inbox storage. In the case + of failures to the database storage (stuff happens), Wolverine will attempt to retry out of band, but allow the message processing + to go through otherwise without triggering error policies so the message is not retried + +::: tip +While we're talking about call outs to external web services, the Wolverine team recommends isolating the call to that web +service in its own handler with isolated error handling and maybe even a circuit breaker for outages of that service. Or at +least making that your default practice. +::: + +You can also opt into this behavior on a message type by message type basis by decorating the +message handler type or handler method with the Wolverine `[Idempotent]` attribute. + ## Handled Message Retention The way that the idempotency checks work is to keep track of messages that have already been processed in the persisted diff --git a/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs b/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs index 0693adc40..37540b9a9 100644 --- a/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs +++ b/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs @@ -1,5 +1,6 @@ using IntegrationTests; using JasperFx; +using JasperFx.CodeGeneration.Frames; using JasperFx.Resources; using Marten; using Microsoft.Data.SqlClient; @@ -15,6 +16,7 @@ using Wolverine.EntityFrameworkCore; using Wolverine.Marten; using Wolverine.Persistence; +using Wolverine.Runtime; using Wolverine.SqlServer; using Wolverine.Tracking; @@ -151,9 +153,60 @@ public async Task happy_and_sad_path_with_message_and_destination_tracking(Idemp tracked2.Discarded.SingleEnvelope().ShouldNotBeNull(); } + + [Fact] + public async Task apply_idempotency_to_non_transactional_handler() + { + #region sample_using_AutoApplyIdempotencyOnNonTransactionalHandlers + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState); + + opts.Policies.AutoApplyTransactions(IdempotencyStyle.Eager); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "idempotency"); + opts.UseEntityFrameworkCoreTransactions(); + + // THIS RIGHT HERE + opts.Policies.AutoApplyIdempotencyOnNonTransactionalHandlers(); + }).StartAsync(); + + #endregion + + var chain = host.GetRuntime().Handlers.ChainFor(); + chain.IsTransactional.ShouldBeFalse(); + chain.Middleware.OfType().Any(x => x.Method.Name == nameof(MessageContext.AssertEagerIdempotencyAsync)).ShouldBeTrue(); + chain.Postprocessors.OfType().Any(x => x.Method.Name == nameof(MessageContext.PersistHandledAsync)).ShouldBeTrue(); + + var messageId = Guid.NewGuid(); + var tracked1 = await host.SendMessageAndWaitAsync(new MaybeIdempotentNotTransactional(messageId)); + + // First time through should be perfectly fine + var sentMessage = tracked1.Executed.SingleEnvelope(); + + var runtime = host.GetRuntime(); + var circuit = runtime.Endpoints.FindListenerCircuit(sentMessage.Destination); + + var tracked2 = await host.TrackActivity() + .DoNotAssertOnExceptionsDetected() + .ExecuteAndWaitAsync(c => + { + sentMessage.WasPersistedInInbox = false; + sentMessage.Attempts = 0; + return circuit.EnqueueDirectlyAsync([sentMessage]); + }); + + tracked2.Discarded.SingleEnvelope().ShouldNotBeNull(); + } } public record MaybeIdempotent(Guid Id); +public record MaybeIdempotentNotTransactional(Guid Id); public static class MaybeIdempotentHandler { @@ -166,4 +219,9 @@ public static void Handle(MaybeIdempotent message, CleanDbContext dbContext) { // Nothing } + + public static void Handle(MaybeIdempotentNotTransactional message) + { + // Nothing + } } diff --git a/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs b/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs index ec3872062..acf344595 100644 --- a/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs +++ b/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs @@ -255,6 +255,19 @@ public override DbCommandBuilder ToCommandBuilder() return new DbCommandBuilder(new NpgsqlCommand()); } + public override async Task ExistsAsync(Envelope envelope, CancellationToken cancellation) + { + if (HasDisposed) return false; + + await using var conn = await NpgsqlDataSource.OpenConnectionAsync(cancellation); + var count = await conn + .CreateCommand($"select count(id) from {SchemaName}.{DatabaseConstants.IncomingTable} where id = :id") + .With("id", envelope.Id) + .ExecuteScalarAsync(cancellation); + + return ((long)count) > 0; + } + public override void WriteLoadScheduledEnvelopeSql(DbCommandBuilder builder, DateTimeOffset utcNow) { builder.Append( diff --git a/src/Persistence/Wolverine.RDBMS/MessageDatabase.cs b/src/Persistence/Wolverine.RDBMS/MessageDatabase.cs index 523b06683..3901ab27e 100644 --- a/src/Persistence/Wolverine.RDBMS/MessageDatabase.cs +++ b/src/Persistence/Wolverine.RDBMS/MessageDatabase.cs @@ -1,6 +1,7 @@ using System.Data; using System.Data.Common; using JasperFx.Core; +using JasperFx.Core.Reflection; using JasperFx.Descriptors; using Microsoft.Extensions.Logging; using Weasel.Core; @@ -222,6 +223,8 @@ public async ValueTask DisposeAsync() public abstract DbCommandBuilder ToCommandBuilder(); + public abstract Task ExistsAsync(Envelope envelope, CancellationToken cancellation); + public async Task ReleaseIncomingAsync(int ownerId, Uri receivedAt) { if (HasDisposed) return; diff --git a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.Inbox.cs b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.Inbox.cs index 3016bb20e..306590830 100644 --- a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.Inbox.cs +++ b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.Inbox.cs @@ -99,6 +99,13 @@ public async Task StoreIncomingAsync(IReadOnlyList envelopes) await session.SaveChangesAsync(); } + public async Task ExistsAsync(Envelope envelope, CancellationToken cancellation) + { + using var session = _store.OpenAsyncSession(); + var identity = IdentityFor(envelope); + return (await session.LoadAsync(identity) == null); + } + public Task RescheduleExistingEnvelopeForRetryAsync(Envelope envelope) { envelope.Status = EnvelopeStatus.Scheduled; diff --git a/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs b/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs index 47775dd93..958d7041a 100644 --- a/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs +++ b/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs @@ -201,6 +201,20 @@ public override DbCommandBuilder ToCommandBuilder() return new DbCommandBuilder(new SqlCommand()); } + public override async Task ExistsAsync(Envelope envelope, CancellationToken cancellation) + { + if (HasDisposed) return false; + + await using var conn = CreateConnection(); + await conn.OpenAsync(cancellation); + var count = await conn + .CreateCommand($"select count(id) from {SchemaName}.{DatabaseConstants.IncomingTable} where id = @id") + .With("id", envelope.Id) + .ExecuteScalarAsync(cancellation); + + return ((int)count) > 0; + } + public override void WriteLoadScheduledEnvelopeSql(DbCommandBuilder builder, DateTimeOffset utcNow) { builder.Append( $"select TOP {Durability.RecoveryBatchSize} {DatabaseConstants.IncomingFields} from {SchemaName}.{DatabaseConstants.IncomingTable} where status = '{EnvelopeStatus.Scheduled}' and execution_time <= "); diff --git a/src/Testing/CoreTests/Configuration/configuring_idempotency_style.cs b/src/Testing/CoreTests/Configuration/configuring_idempotency_style.cs index 57058e9f2..df96906bf 100644 --- a/src/Testing/CoreTests/Configuration/configuring_idempotency_style.cs +++ b/src/Testing/CoreTests/Configuration/configuring_idempotency_style.cs @@ -58,6 +58,10 @@ public async Task use_transactional_policies_to_eager() await host.InvokeAsync(new TM4(Guid.NewGuid())); var runtime = host.GetRuntime(); + + // Just seeing that this caught + runtime.Handlers.ChainFor().IsTransactional.ShouldBeTrue(); + runtime.Handlers.ChainFor().Idempotency.ShouldBe(IdempotencyStyle.Eager); // Override by transactional attribute! diff --git a/src/Testing/CoreTests/Runtime/Handlers/HandlerChainTests.cs b/src/Testing/CoreTests/Runtime/Handlers/HandlerChainTests.cs index 0483d9374..939298a45 100644 --- a/src/Testing/CoreTests/Runtime/Handlers/HandlerChainTests.cs +++ b/src/Testing/CoreTests/Runtime/Handlers/HandlerChainTests.cs @@ -18,6 +18,13 @@ public void the_default_log_level_is_information() chain.SuccessLogLevel.ShouldBe(LogLevel.Information); } + [Fact] + public void is_transactional_is_false_by_default() + { + var chain = HandlerChain.For(x => x.Go(null), null); + chain.IsTransactional.ShouldBeFalse(); + } + [Fact] public void default_idempotency_is_none() { diff --git a/src/Testing/Wolverine.ComplianceTests/MessageStoreCompliance.cs b/src/Testing/Wolverine.ComplianceTests/MessageStoreCompliance.cs index 532a2319a..452de4f55 100644 --- a/src/Testing/Wolverine.ComplianceTests/MessageStoreCompliance.cs +++ b/src/Testing/Wolverine.ComplianceTests/MessageStoreCompliance.cs @@ -105,6 +105,20 @@ public async Task store_a_single_incoming_envelope() stored.SentAt.ShouldBe(envelope.SentAt); } + + [Fact] + public async Task incoming_exists() + { + var envelope = ObjectMother.Envelope(); + envelope.Status = EnvelopeStatus.Incoming; + envelope.SentAt = ((DateTimeOffset)DateTime.Today).ToUniversalTime(); + + (await thePersistence.Inbox.ExistsAsync(envelope, CancellationToken.None)).ShouldBeFalse(); + + await thePersistence.Inbox.StoreIncomingAsync(envelope); + + (await thePersistence.Inbox.ExistsAsync(envelope, CancellationToken.None)).ShouldBeTrue(); + } [Fact] public async Task store_a_single_incoming_envelope_that_is_handled() diff --git a/src/Wolverine/Attributes/IdempotentAttribute.cs b/src/Wolverine/Attributes/IdempotentAttribute.cs new file mode 100644 index 000000000..1e22d182d --- /dev/null +++ b/src/Wolverine/Attributes/IdempotentAttribute.cs @@ -0,0 +1,18 @@ +using JasperFx.CodeGeneration; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Attributes; + +/// +/// Adds idempotency checks to this message handler +/// +/// ONLY use this for message handlers that do not use transactional +/// middleware +/// +public class IdempotentAttribute : ModifyHandlerChainAttribute +{ + public override void Modify(HandlerChain chain, GenerationRules rules) + { + chain.ApplyIdempotencyCheck(); + } +} \ No newline at end of file diff --git a/src/Wolverine/Attributes/TransactionalAttribute.cs b/src/Wolverine/Attributes/TransactionalAttribute.cs index 6c53b2d2c..c4eecf2d0 100644 --- a/src/Wolverine/Attributes/TransactionalAttribute.cs +++ b/src/Wolverine/Attributes/TransactionalAttribute.cs @@ -24,6 +24,8 @@ public override void Modify(IChain chain, GenerationRules rules, IServiceContain chain.ApplyImpliedMiddlewareFromHandlers(rules); var transactionFrameProvider = rules.As().GetPersistenceProviders(chain, container); transactionFrameProvider.ApplyTransactionSupport(chain, container); + + chain.IsTransactional = true; } public IdempotencyStyle? Idempotency { get; set; } diff --git a/src/Wolverine/Configuration/Chain.cs b/src/Wolverine/Configuration/Chain.cs index 9f44045e8..7e75d608e 100644 --- a/src/Wolverine/Configuration/Chain.cs +++ b/src/Wolverine/Configuration/Chain.cs @@ -45,6 +45,7 @@ public abstract class Chain : IChain public abstract bool TryInferMessageIdentity(out PropertyInfo? property); + public bool IsTransactional { get; set; } public abstract bool ShouldFlushOutgoingMessages(); public abstract bool RequiresOutbox(); diff --git a/src/Wolverine/Configuration/IChain.cs b/src/Wolverine/Configuration/IChain.cs index 282362d8d..4b4c9b038 100644 --- a/src/Wolverine/Configuration/IChain.cs +++ b/src/Wolverine/Configuration/IChain.cs @@ -68,6 +68,11 @@ public interface IChain /// IReturnVariableActionSource ReturnVariableActionSource { get; set; } + /// + /// Does this chain have any transactional middleware attached to it? + /// + bool IsTransactional { get; set; } + /// /// Used internally by Wolverine for "outbox" mechanics /// diff --git a/src/Wolverine/IPolicies.cs b/src/Wolverine/IPolicies.cs index dc19f1582..cc5d646cb 100644 --- a/src/Wolverine/IPolicies.cs +++ b/src/Wolverine/IPolicies.cs @@ -82,6 +82,13 @@ public interface IPolicies : IEnumerable, IWithFailurePolicies /// Define a default IdempotencyStyle for message handlers executing in Buffered or Inline endpoints void AutoApplyTransactions(IdempotencyStyle idempotency); + /// + /// Apply eager message idempotency checks to any message handler chains that are not otherwise transactional. + /// Example is a handler that calls an external web service but does not make any changes to the current system's + /// databases or storage + /// + void AutoApplyIdempotencyOnNonTransactionalHandlers(); + /// /// Add Wolverine middleware to message handlers /// diff --git a/src/Wolverine/Persistence/AutoApplyTransactions.cs b/src/Wolverine/Persistence/AutoApplyTransactions.cs index a6b91120e..df7cd89a0 100644 --- a/src/Wolverine/Persistence/AutoApplyTransactions.cs +++ b/src/Wolverine/Persistence/AutoApplyTransactions.cs @@ -28,6 +28,7 @@ public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceC if (potentials.Length == 1) { potentials.Single().ApplyTransactionSupport(chain, container); + chain.IsTransactional = true; } } } diff --git a/src/Wolverine/Persistence/Durability/DelegatingMessageInbox.cs b/src/Wolverine/Persistence/Durability/DelegatingMessageInbox.cs index 2bf6f4fcc..f2e20bb92 100644 --- a/src/Wolverine/Persistence/Durability/DelegatingMessageInbox.cs +++ b/src/Wolverine/Persistence/Durability/DelegatingMessageInbox.cs @@ -76,4 +76,9 @@ public async Task ReleaseIncomingAsync(int ownerId, Uri receivedAt) await database.Inbox.ReleaseIncomingAsync(ownerId, receivedAt); } } + + public Task ExistsAsync(Envelope envelope, CancellationToken cancellation) + { + return _inner.ExistsAsync(envelope, cancellation); + } } \ No newline at end of file diff --git a/src/Wolverine/Persistence/Durability/IMessageStore.cs b/src/Wolverine/Persistence/Durability/IMessageStore.cs index 615bf44f0..f71f3ab14 100644 --- a/src/Wolverine/Persistence/Durability/IMessageStore.cs +++ b/src/Wolverine/Persistence/Durability/IMessageStore.cs @@ -39,6 +39,7 @@ public interface IMessageInbox Task StoreIncomingAsync(Envelope envelope); Task StoreIncomingAsync(IReadOnlyList envelopes); + Task ExistsAsync(Envelope envelope, CancellationToken cancellation); Task MarkIncomingEnvelopeAsHandledAsync(Envelope envelope); diff --git a/src/Wolverine/Persistence/Durability/MultiTenantedMessageStore.cs b/src/Wolverine/Persistence/Durability/MultiTenantedMessageStore.cs index bc827bbfa..60d40f9ae 100644 --- a/src/Wolverine/Persistence/Durability/MultiTenantedMessageStore.cs +++ b/src/Wolverine/Persistence/Durability/MultiTenantedMessageStore.cs @@ -144,6 +144,12 @@ async Task IMessageInbox.StoreIncomingAsync(IReadOnlyList envelopes) } } + public async Task ExistsAsync(Envelope envelope, CancellationToken cancellation) + { + var database = await GetDatabaseAsync(envelope.TenantId); + return await database.Inbox.ExistsAsync(envelope, cancellation); + } + async Task IMessageInbox.RescheduleExistingEnvelopeForRetryAsync(Envelope envelope) { var database = await GetDatabaseAsync(envelope.TenantId); diff --git a/src/Wolverine/Persistence/Durability/NullMessageStore.cs b/src/Wolverine/Persistence/Durability/NullMessageStore.cs index 4df27582a..010c4c5fc 100644 --- a/src/Wolverine/Persistence/Durability/NullMessageStore.cs +++ b/src/Wolverine/Persistence/Durability/NullMessageStore.cs @@ -14,6 +14,10 @@ public class NullMessageStore : IMessageStore, IMessageInbox, IMessageOutbox, IM { internal IScheduledJobProcessor? ScheduledJobs { get; set; } + public Task ExistsAsync(Envelope envelope, CancellationToken cancellation) + { + return Task.FromResult(false); + } public MessageStoreRole Role => MessageStoreRole.Main; public Uri Uri => new Uri($"{PersistenceConstants.AgentScheme}://null"); diff --git a/src/Wolverine/Persistence/Durability/PersistHandled.cs b/src/Wolverine/Persistence/Durability/PersistHandled.cs new file mode 100644 index 000000000..cf318414e --- /dev/null +++ b/src/Wolverine/Persistence/Durability/PersistHandled.cs @@ -0,0 +1,14 @@ +using Wolverine.Runtime; +using Wolverine.Runtime.Agents; + +namespace Wolverine.Persistence.Durability; + +public class PersistHandled(Envelope Handled) : IAgentCommand +{ + public async Task ExecuteAsync(IWolverineRuntime runtime, CancellationToken cancellationToken) + { + await runtime.Storage.Inbox.StoreIncomingAsync(Handled); + + return AgentCommands.Empty; + } +} \ No newline at end of file diff --git a/src/Wolverine/Persistence/EagerIdempotencyOnNonTransactionalChains.cs b/src/Wolverine/Persistence/EagerIdempotencyOnNonTransactionalChains.cs new file mode 100644 index 000000000..81c98794f --- /dev/null +++ b/src/Wolverine/Persistence/EagerIdempotencyOnNonTransactionalChains.cs @@ -0,0 +1,19 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using Wolverine.Configuration; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Persistence; + +internal class EagerIdempotencyOnNonTransactionalChains : IHandlerPolicy +{ + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + foreach (var handlerChain in chains.Where(x => !x.IsTransactional)) + { + handlerChain.ApplyIdempotencyCheck(); + } + } +} \ No newline at end of file diff --git a/src/Wolverine/Runtime/Handlers/HandlerChain.cs b/src/Wolverine/Runtime/Handlers/HandlerChain.cs index 33f6a126f..40a337bfe 100644 --- a/src/Wolverine/Runtime/Handlers/HandlerChain.cs +++ b/src/Wolverine/Runtime/Handlers/HandlerChain.cs @@ -116,6 +116,13 @@ public HandlerChain(WolverineOptions options, IGrouping group } } + internal void ApplyIdempotencyCheck() + { + Middleware.Insert(0, MethodCall.For(x => x.AssertEagerIdempotencyAsync(CancellationToken.None))); + + Postprocessors.Add(MethodCall.For(x => x.PersistHandledAsync())); + } + protected virtual void validateAgainstInvalidSagaMethods(IGrouping grouping) { var illegalSagas = grouping.Where(x => x.HandlerType.CanBeCastTo() && x.Method.IsStatic).ToArray(); diff --git a/src/Wolverine/Runtime/MessageContext.cs b/src/Wolverine/Runtime/MessageContext.cs index ff253a48a..6cac86215 100644 --- a/src/Wolverine/Runtime/MessageContext.cs +++ b/src/Wolverine/Runtime/MessageContext.cs @@ -80,7 +80,16 @@ private bool isMissingRequestedReply() public async Task AssertEagerIdempotencyAsync(CancellationToken cancellation) { if (Envelope == null || Envelope.WasPersistedInInbox ) return; - if (Transaction == null) return; + if (Transaction == null) + { + var exists = await Runtime.Storage.Inbox.ExistsAsync(Envelope, cancellation); + if (exists) + { + throw new DuplicateIncomingEnvelopeException(Envelope); + } + + return; + } var check = await Transaction.TryMakeEagerIdempotencyCheckAsync(Envelope, Runtime.Options.Durability, cancellation); if (!check) @@ -91,6 +100,22 @@ public async Task AssertEagerIdempotencyAsync(CancellationToken cancellation) Envelope.WasPersistedInInbox = true; } + public async Task PersistHandledAsync() + { + var handled = Envelope.ForPersistedHandled(Envelope, DateTimeOffset.UtcNow, Runtime.Options.Durability); + try + { + await Runtime.Storage.Inbox.StoreIncomingAsync(handled); + } + catch (Exception e) + { + Runtime.Logger.LogError(e, "Error trying to mark message {Id} as handled. Retrying later.", handled.Id); + + // Retry this off to the side... + await new MessageBus(Runtime).PublishAsync(new PersistHandled(handled)); + } + } + public async Task FlushOutgoingMessagesAsync() { if (_hasFlushed) diff --git a/src/Wolverine/WolverineOptions.Policies.cs b/src/Wolverine/WolverineOptions.Policies.cs index 654957621..cf9310cc2 100644 --- a/src/Wolverine/WolverineOptions.Policies.cs +++ b/src/Wolverine/WolverineOptions.Policies.cs @@ -50,6 +50,11 @@ void IPolicies.AutoApplyTransactions(IdempotencyStyle idempotency) RegisteredPolicies.Insert(0, new AutoApplyTransactions{Idempotency = idempotency}); } + void IPolicies.AutoApplyIdempotencyOnNonTransactionalHandlers() + { + RegisteredPolicies.Add(new EagerIdempotencyOnNonTransactionalChains()); + } + void IPolicies.Add() { this.As().Add(new T());