From 836e5bf36bc223e6fd46f0177459d43b4eb691d6 Mon Sep 17 00:00:00 2001 From: stidsborg Date: Sun, 5 Jan 2025 08:15:29 +0100 Subject: [PATCH] Improved samples --- .../Clients/LogisticsClient.cs | 9 ++ .../Clients/PaymentProviderClient.cs | 6 ++ .../Flows/BankTransfer/IBankCentralClient.cs | 20 +++++ .../Flows/BankTransfer/Transfer.cs | 8 ++ .../Flows/BankTransfer/TransferFlow.cs | 29 +++++++ .../Flows/Batch/BatchOrderFlow.cs | 2 - .../Flows/MessageDriven/Other/Bus.cs | 2 +- .../Flows/MessageDriven/Other/Setup.cs | 2 +- .../Solution/MessageDrivenOrderFlow.cs | 11 +-- .../Flows/Rpc/OrderFlow.cs | 40 ++++++++- .../Flows/Rpc/Solution/OrderFlow.cs | 1 - .../Rpc/Solution/OrderFlowWithCleanUp.cs | 83 +++++++++++++++++++ 12 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/IBankCentralClient.cs create mode 100644 Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/Transfer.cs create mode 100644 Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/TransferFlow.cs create mode 100644 Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlowWithCleanUp.cs diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/LogisticsClient.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/LogisticsClient.cs index b844260..bdea19a 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/LogisticsClient.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/LogisticsClient.cs @@ -5,6 +5,7 @@ namespace Cleipnir.Flows.Sample.MicrosoftOpen.Clients; public interface ILogisticsClient { Task ShipProducts(Guid customerId, IEnumerable productIds); + Task CancelShipment(TrackAndTrace trackAndTrace); } public record TrackAndTrace(string Value); @@ -18,4 +19,12 @@ public Task ShipProducts(Guid customerId, IEnumerable produ return new TrackAndTrace(Guid.NewGuid().ToString()); } ); + + public Task CancelShipment(TrackAndTrace trackAndTrace) + => Task.Delay(ClientSettings.Delay).ContinueWith(_ => + { + Log.Logger.ForContext().Information("LOGISTICS_SERVER: Products shipment cancelled"); + return new TrackAndTrace(Guid.NewGuid().ToString()); + } + ); } \ No newline at end of file diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/PaymentProviderClient.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/PaymentProviderClient.cs index 6fd1126..d4e8f4d 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/PaymentProviderClient.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Clients/PaymentProviderClient.cs @@ -7,6 +7,7 @@ public interface IPaymentProviderClient Task Reserve(Guid transactionId, Guid customerId, decimal amount); Task Capture(Guid transactionId); Task CancelReservation(Guid transactionId); + Task Reverse(Guid transactionId); bool IsServiceDown(); } @@ -26,5 +27,10 @@ public Task CancelReservation(Guid transactionId) Log.Logger.ForContext().Information("PAYMENT_PROVIDER: Reservation cancelled") ); + public Task Reverse(Guid transactionId) + => Task.Delay(ClientSettings.Delay).ContinueWith(_ => + Log.Logger.ForContext().Information("PAYMENT_PROVIDER: Reservation reversed") + ); + public bool IsServiceDown() => false; } \ No newline at end of file diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/IBankCentralClient.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/IBankCentralClient.cs new file mode 100644 index 0000000..3902c55 --- /dev/null +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/IBankCentralClient.cs @@ -0,0 +1,20 @@ +using Cleipnir.ResilientFunctions.Helpers; + +namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.BankTransfer; + +public interface IBankCentralClient +{ + Task PostTransaction(Guid transactionId, string account, decimal amount); + Task GetAvailableFunds(string account); +} + +public class BankCentralClient : IBankCentralClient +{ + public Task PostTransaction(Guid transactionId, string account, decimal amount) + { + Console.WriteLine($"POSTING: {amount} to {account} account"); + return Task.Delay(1_000).ContinueWith(_ => true); + } + + public Task GetAvailableFunds(string account) => 100M.ToTask(); +} \ No newline at end of file diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/Transfer.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/Transfer.cs new file mode 100644 index 0000000..fc0f515 --- /dev/null +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/Transfer.cs @@ -0,0 +1,8 @@ +namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.BankTransfer; + +public record Transfer( + Guid TransactionId, + string FromAccount, + string ToAccount, + decimal Amount +); \ No newline at end of file diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/TransferFlow.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/TransferFlow.cs new file mode 100644 index 0000000..164a8c5 --- /dev/null +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/BankTransfer/TransferFlow.cs @@ -0,0 +1,29 @@ +using Cleipnir.ResilientFunctions.Domain; + +namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.BankTransfer; + +[GenerateFlows] +public class TransferFlow(IBankCentralClient bankCentralClient) : Flow +{ + public override async Task Run(Transfer transfer) + { + var availableFunds = await bankCentralClient.GetAvailableFunds(transfer.FromAccount); + if (availableFunds <= transfer.Amount) + throw new InvalidOperationException("Insufficient funds on from account"); + + await bankCentralClient.PostTransaction( + transfer.TransactionId, + transfer.FromAccount, + -transfer.Amount + ); + + await bankCentralClient.PostTransaction( + transfer.TransactionId, + transfer.ToAccount, + transfer.Amount + ); + } + + private DistributedSemaphore DistributedLock(string account) + => Workflow.Semaphores.Create("BankTransfer", account, maximumCount: 1); +} \ No newline at end of file diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Batch/BatchOrderFlow.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Batch/BatchOrderFlow.cs index fbede29..0f398bd 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Batch/BatchOrderFlow.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Batch/BatchOrderFlow.cs @@ -1,7 +1,5 @@ using System.Diagnostics; using Cleipnir.Flows.Sample.MicrosoftOpen.Clients; -using Cleipnir.ResilientFunctions; -using Cleipnir.ResilientFunctions.Domain; namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.Batch; diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Bus.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Bus.cs index a9d946c..9b23f58 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Bus.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Bus.cs @@ -1,6 +1,6 @@ namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.MessageDriven.Other; -public class Bus(Solution.MessageDrivenOrderFlows flows) +public class Bus(MessageDrivenOrderFlows flows) { private readonly List> _subscribers = new(); private readonly object _lock = new(); diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Setup.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Setup.cs index 6c59317..b410f72 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Setup.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Other/Setup.cs @@ -6,7 +6,7 @@ public static void AddInMemoryBus(this IServiceCollection services) { services.AddSingleton(p => { - var orderFlows = p.GetRequiredService(); + var orderFlows = p.GetRequiredService(); var bus = new Bus(orderFlows); var emailService = new EmailServiceStub(bus); diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Solution/MessageDrivenOrderFlow.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Solution/MessageDrivenOrderFlow.cs index 30b7618..5cacc1f 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Solution/MessageDrivenOrderFlow.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/MessageDriven/Solution/MessageDrivenOrderFlow.cs @@ -2,12 +2,11 @@ namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.MessageDriven.Solution; -[GenerateFlows] public class MessageDrivenOrderFlow(Bus bus) : Flow { public override async Task Run(Order order) { - var transactionId = await Effect.Capture(Guid.NewGuid); + var transactionId = await Capture(Guid.NewGuid); await ReserveFunds(order, transactionId); var reservation = await Message(TimeSpan.FromSeconds(10)); @@ -34,17 +33,17 @@ private async Task CleanUp(FailedAt failedAt, Order order, Guid transactionId) switch (failedAt) { case FailedAt.FundsReserved: - await CancelFundsReservation(order, transactionId); break; case FailedAt.ProductsShipped: await CancelFundsReservation(order, transactionId); - await CancelProductsShipment(order); break; case FailedAt.FundsCaptured: + await CancelFundsReservation(order, transactionId); await CancelProductsShipment(order); break; case FailedAt.OrderConfirmationEmailSent: - //we accept this failure without cleaning up + await ReversePayment(order, transactionId); + await CancelProductsShipment(order); break; default: throw new ArgumentOutOfRangeException(nameof(failedAt), failedAt, null); @@ -73,4 +72,6 @@ private Task CancelProductsShipment(Order order) => Capture(() => bus.Send(new CancelProductsShipment(order.OrderId))); private Task CancelFundsReservation(Order order, Guid transactionId) => Capture(() => bus.Send(new CancelFundsReservation(order.OrderId, transactionId))); + private Task ReversePayment(Order order, Guid transactionId) + => Capture(() => bus.Send(new ReverseTransaction(order.OrderId, transactionId))); } diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/OrderFlow.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/OrderFlow.cs index 94a60f9..e59af37 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/OrderFlow.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/OrderFlow.cs @@ -1,4 +1,5 @@ using Cleipnir.Flows.Sample.MicrosoftOpen.Clients; +using Cleipnir.Flows.Sample.MicrosoftOpen.Flows.MessageDriven; using Polly; using Polly.Retry; @@ -20,7 +21,7 @@ public override async Task Run(Order order) await paymentProviderClient.Capture(transactionId); await emailClient.SendOrderConfirmation(order.CustomerId, trackAndTrace, order.ProductIds); } - + #region Polly private ResiliencePipeline Pipeline { get; } = new ResiliencePipelineBuilder() @@ -36,4 +37,41 @@ public override async Task Run(Order order) ).Build(); #endregion + + #region CleanUp + + private async Task CleanUp(FailedAt failedAt, Guid transactionId, TrackAndTrace? trackAndTrace) + { + switch (failedAt) + { + case FailedAt.FundsReserved: + break; + case FailedAt.ProductsShipped: + await paymentProviderClient.CancelReservation(transactionId); + break; + case FailedAt.FundsCaptured: + await paymentProviderClient.Reverse(transactionId); + await logisticsClient.CancelShipment(trackAndTrace!); + break; + case FailedAt.OrderConfirmationEmailSent: + //we accept this failure without cleaning up + break; + default: + throw new ArgumentOutOfRangeException(nameof(failedAt), failedAt, null); + } + + throw new OrderProcessingException($"Order processing failed at: '{failedAt}'"); + } + + private record StepAndCleanUp(Func Work, Func CleanUp); + + private enum FailedAt + { + FundsReserved, + ProductsShipped, + FundsCaptured, + OrderConfirmationEmailSent, + } + + #endregion } \ No newline at end of file diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlow.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlow.cs index 89e574d..2f3ebfd 100644 --- a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlow.cs +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlow.cs @@ -3,7 +3,6 @@ namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.Rpc.Solution; -[GenerateFlows] public class OrderFlow( IPaymentProviderClient paymentProviderClient, IEmailClient emailClient, diff --git a/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlowWithCleanUp.cs b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlowWithCleanUp.cs new file mode 100644 index 0000000..468bbc6 --- /dev/null +++ b/Samples/Cleipnir.Flows.Sample.Presentation.AspNet/Flows/Rpc/Solution/OrderFlowWithCleanUp.cs @@ -0,0 +1,83 @@ +using Cleipnir.Flows.Sample.MicrosoftOpen.Clients; + +namespace Cleipnir.Flows.Sample.MicrosoftOpen.Flows.Rpc.Solution; + +public class OrderFlowWithCleanUp( + IPaymentProviderClient paymentProviderClient, + IEmailClient emailClient, + ILogisticsClient logisticsClient) + : Flow +{ + public override async Task Run(Order order) + { + var transactionId = Guid.NewGuid(); + + TrackAndTrace? trackAndTrace = null; + var steps = new StepAndCleanUp[] + { + new( + Work: () => paymentProviderClient.Reserve(transactionId, order.CustomerId, order.TotalPrice), + CleanUp: () => CleanUp(FailedAt.ProductsShipped, transactionId, trackAndTrace: null) + ), + new( + Work: async () => + { + trackAndTrace = await Capture(async () => + await logisticsClient.ShipProducts(order.CustomerId, order.ProductIds) + ); + }, + CleanUp: () => CleanUp(FailedAt.ProductsShipped, transactionId, trackAndTrace: null) + ), + new( + Work: () => paymentProviderClient.Capture(transactionId), + CleanUp: () => CleanUp(FailedAt.FundsCaptured, transactionId, trackAndTrace) + ), + new ( + Work: () => emailClient.SendOrderConfirmation(order.CustomerId, trackAndTrace!, order.ProductIds), + CleanUp: () => CleanUp(FailedAt.OrderConfirmationEmailSent, transactionId, trackAndTrace) + ) + }; + + foreach (var step in steps) + try + { + await step.Work(); + } + catch (Exception) + { + await step.CleanUp(); + throw; + } + } + + private async Task CleanUp(FailedAt failedAt, Guid transactionId, TrackAndTrace? trackAndTrace) + { + switch (failedAt) + { + case FailedAt.FundsReserved: + break; + case FailedAt.ProductsShipped: + await paymentProviderClient.CancelReservation(transactionId); + break; + case FailedAt.FundsCaptured: + await paymentProviderClient.Reverse(transactionId); + await logisticsClient.CancelShipment(trackAndTrace!); + break; + case FailedAt.OrderConfirmationEmailSent: + //we accept this failure without cleaning up + break; + default: + throw new ArgumentOutOfRangeException(nameof(failedAt), failedAt, null); + } + } + + private record StepAndCleanUp(Func Work, Func CleanUp); + + private enum FailedAt + { + FundsReserved, + ProductsShipped, + FundsCaptured, + OrderConfirmationEmailSent, + } +} \ No newline at end of file