Skip to content

Commit

Permalink
Merge pull request #2 from temporalio/rollback-with-saga
Browse files Browse the repository at this point in the history
Saga Pattern Exercise
  • Loading branch information
angelazhou32 authored Jan 22, 2025
2 parents 599464a + a016297 commit 1bb7c00
Show file tree
Hide file tree
Showing 31 changed files with 821 additions and 0 deletions.
64 changes: 64 additions & 0 deletions exercises/rollback-with-saga/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Exercise #3: Rollback with the Saga Pattern

During this exercise, you will:

- Orchestrate Activities using a Saga pattern to implement compensating transactions
- Handle failures with rollback logic

Make your changes to the code in the `practice` subdirectory (look for `TODO` comments that will guide you to where you should make changes to the code). If you need a hint or want to verify your changes, look at the complete version in the `solution` subdirectory.

## Part A: Review your rollback Activities

This Exercise uses the same structure as in the previous Exercises — meaning that it will fail at the very end on the `ValidateCreditCard` Activity if you provide it with a bad credit card number.

Three new Activities have been created to demonstrate rollback actions.

* `UpdateInventory` reduces the stock from the pizza inventory once the pizza order comes through.
* `RevertInventory` has also been added as a compensating action for `UpdateInventory`. It add the ingredients back into the pizza inventory.
* `RefundCustomer` has been added as a compensating action for `SendBill`.

1. Review these new Activities in `Activities.cs` in the `Workflow` directory. None of them make actual inventory or billing changes, because the intent of this Activity is to show Temporal features, but you should be able to see where you could add functionality here.
2. Close the files.

## Part B: Add your new rollback Activities to your Workflow

Now you will implement a compensating action using Activities in your Temporal Workflow.

Also, note that we register compensating actions before executing their corresponding Activities. Consider this example of why we use this pattern.

- An Activity (like `UpdateInventory`) executes successfully and updates the inventory system.
- However, right after the update succeeds, a network failure occurs.
- The Activity fails to report its success back to the Workflow
From the Workflow's perspective, the Activity failed.
- If we had registered the compensation after the activity, the compensation would never be registered

1. Open `PizzaWorkflow.cs` from your `Workflow` directory.
2. Note that a List, `compensations`, has been added at the top to keep track of each Activity's compensating action.
3. Note that after the bill is created in the `PizzaWorkflow` file, the `UpdateInventory` Activity is executed, before the `SendBill` Activity is called. The compensating action was added to the compensations list in the list above. Study this and use it for the next step.
4. Locate the invocation for the `SendBill` Activity. Add the appropriate compensating Activity to the compensations list, containing the compensating input. Use the previous step as a reference.

## Part C: Create Your `Compensate` Function

In this part of the exercise, you will create a function which will loop through the each one of the items in your `compensations` list in a synchronous order. In the case of an error, we will invoke this function to roll back on any Activities we want to undo.

1. In the `PizzaWorkflow.cs` file, locate the `Compensate` Task after the `PizzaWorkflow`.
2. Call the `Reverse` method before you loop through the `compensations` list. This ensures that compensating actions are called in reverse order, aligning with the correct sequence to roll back local transactions that have already completed.

## Part D: Add your `Compensate` Task to your Workflow

In this part of the exercise, you will call the `Compensate` function that you defined in Part C.

1. In the `PizzaWorkflow.cs` file, notice you have a `try/catch` block. You call your Activities in the `try` block. In the `catch` block, if an error occurs, we want to roll back all of the Activities that have so far executed by calling the compensating actions.
2. In the `catch` block of the `PizzaWorkflow`, call `await CompensateAsync()`. Now if `ValidateCreditCard` fails, first we roll back on `SendBill` by calling `RefundCustomer`. Next, we will roll back on `UpdateInventory` by calling `RevertInventory`.
3. Save the file.

## Part E: Test the Rollback of Your Activities

To run the Workflow:

1. In one terminal, start the Worker by running `dotnet run --project Worker`.
2. In another terminal, start the Workflow by running `dotnet run --project Client`.
3. You should see the Workflow Execution failed. There is now a `WorkflowExecutionFailed` Event in the Web UI.
4. Over in the Web UI (or the terminal window where your Worker ran), you can see that after the `ValidateCreditCard` Activity failed, we then called the Activities: `RefundCustomer` and `RevertInventory`.

### This is the end of the exercise.
64 changes: 64 additions & 0 deletions exercises/rollback-with-saga/practice/Client/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// This file is designated to run the Workflow
using System.Collections.ObjectModel;
using Microsoft.Extensions.Logging;
using Temporalio.Client;
using TemporalioSagaPattern.Practice.Workflow;
using TemporalioSagaPattern.Practice.Workflow.Models;

// Create a client to localhost on "default" namespace
var client = await TemporalClient.ConnectAsync(new("localhost:7233")
{
LoggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] ").SetMinimumLevel(LogLevel.Information)),
});

var order = CreatePizzaOrder();

// Run workflow
var result = await client.ExecuteWorkflowAsync(
(PizzaWorkflow wf) => wf.RunAsync(order),
new WorkflowOptions
{
Id = $"pizza-workflow-order-{order.OrderNumber}",
TaskQueue = WorkflowConstants.TaskQueueName,
});

Console.WriteLine($"""
Workflow result:
Order Number: {result.OrderNumber}
Status: {result.Status}
Confirmation Number: {result.ConfirmationNumber}
Billing Timestamp: {result.BillingTimestamp}
Amount: {result.Amount}
""");

PizzaOrder CreatePizzaOrder()
{
var customer = new Customer(
CustomerId: 12983,
Name: "María García",
Email: "maria1985@example.com",
Phone: "415-555-7418",
CreditCardNumber: "1234567890123456123");

var address = new Address(
Line1: "701 Mission Street",
Line2: "Apartment 9C",
City: "San Francisco",
State: "CA",
PostalCode: "94103");

var p1 = new Pizza(Description: "Large, with mushrooms and onions", Price: 1500);
var p2 = new Pizza(Description: "Small, with pepperoni", Price: 1200);
var p3 = new Pizza(Description: "Medium, with extra cheese", Price: 1300);

var pizzaList = new List<Pizza> { p1, p2, p3 };
var pizzas = new Collection<Pizza>(pizzaList);

return new PizzaOrder(
OrderNumber: "Z1238",
Customer: customer,
Items: pizzas,
Address: address,
IsDelivery: true);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Workflow\TemporalioRollbackWithSaga.Practice.Workflow.csproj" />
</ItemGroup>
</Project>
39 changes: 39 additions & 0 deletions exercises/rollback-with-saga/practice/Worker/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// This file is designated to run the Worker
using Microsoft.Extensions.Logging;
using Temporalio.Client;
using Temporalio.Worker;
using TemporalioSagaPattern.Practice.Workflow;

// Create a client to localhost on "default" namespace
var client = await TemporalClient.ConnectAsync(new("localhost:7233")
{
LoggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] ").SetMinimumLevel(LogLevel.Information)),
});

// Cancellation token to shutdown worker on ctrl+c
using var tokenSource = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
tokenSource.Cancel();
eventArgs.Cancel = true;
};

var activities = new Activities();

// Create worker
using var worker = new TemporalWorker(
client,
new TemporalWorkerOptions(WorkflowConstants.TaskQueueName)
.AddAllActivities(activities)
.AddWorkflow<PizzaWorkflow>());

// Run worker until cancelled
Console.WriteLine("Running worker");
try
{
await worker.ExecuteAsync(tokenSource.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Worker cancelled");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Workflow\TemporalioRollbackWithSaga.Practice.Workflow.csproj" />
</ItemGroup>
</Project>
107 changes: 107 additions & 0 deletions exercises/rollback-with-saga/practice/Workflow/Activities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
namespace TemporalioSagaPattern.Practice.Workflow;

using Microsoft.Extensions.Logging;
using Temporalio.Activities;
using Temporalio.Exceptions;
using TemporalioSagaPattern.Practice.Workflow.Models;

public class Activities
{
[Activity]
public Task<Distance> GetDistanceAsync(Address address)
{
var logger = ActivityExecutionContext.Current.Logger;
logger.LogInformation("GetDistance invoked; determining distance to customer address");

// This is a simulation, which calculates a fake (but consistent)
// distance for a customer address based on its length. The value
// will therefore be different when called with different addresses,
// but will be the same across all invocations with the same address.
var kilometers = address.Line1.Length + address.Line2.Length - 10;
if (kilometers < 1)
{
kilometers = 5;
}

var distance = new Distance(kilometers);

logger.LogInformation("GetDistance complete. Distance: {Distance}", distance.Kilometers);
return Task.FromResult(distance);
}

[Activity]
public string ValidateCreditCard(string creditCardNumber)
{
var logger = ActivityExecutionContext.Current.Logger;
logger.LogInformation("ValidateCreditCard invoked {Amount}", creditCardNumber);

if (creditCardNumber.Length != 16)
{
throw new ApplicationFailureException("Invalid credit card number: must contain exactly 16 digits", nonRetryable: true);
}

return "Credit card validated";
}

[Activity]
public string UpdateInventory(ICollection<Pizza> items)
{
var logger = ActivityExecutionContext.Current.Logger;
// Here you would call your inventory management system to reduce the stock of your pizza inventory
logger.LogInformation("Updating inventory for {Count} items", items.Count);
return "Updated inventory";
}

[Activity]
public string RevertInventory(ICollection<Pizza> items)
{
var logger = ActivityExecutionContext.Current.Logger;
// Here you would call your inventory management system to add the ingredients back into the pizza inventory.
logger.LogInformation("Reverting inventory for {Count} items", items.Count);
return "Reverted inventory";
}

[Activity]
public string RefundCustomer(Bill bill)
{
var logger = ActivityExecutionContext.Current.Logger;
// Simulate refunding the customer
logger.LogInformation(
"Refunding {Amount} to customer {CustomerId} for order {OrderNumber}",
bill.Amount,
bill.CustomerId,
bill.OrderNumber);
return "Refunded {Amount} to customer {CustomerId} for order {OrderNumber}";
}

[Activity]
public Task<OrderConfirmation> SendBillAsync(Bill bill)
{
var logger = ActivityExecutionContext.Current.Logger;
logger.LogInformation("SendBill invoked. Customer: {Customer}, Amount: {Amount}", bill.CustomerId, bill.Amount);

var chargeAmount = bill.Amount;

if (bill.Amount > 3000)
{
logger.LogInformation("Applying discount");
chargeAmount -= 500;
}

if (chargeAmount < 0)
{
throw new ArgumentException($"Invalid charge amount: {chargeAmount} (must be above zero)");
}

var confirmation = new OrderConfirmation(
OrderNumber: bill.OrderNumber,
Status: "SUCCESS",
ConfirmationNumber: "AB9923",
BillingTimestamp: DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Amount: chargeAmount);

logger.LogInformation("SendBill complete. ConfirmationNumber: {Confirmation}", confirmation.ConfirmationNumber);

return Task.FromResult(confirmation);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace TemporalioSagaPattern.Practice.Workflow.Models;

public record Address(
string Line1,
string City,
string State,
string PostalCode,
string Line2 = "");
7 changes: 7 additions & 0 deletions exercises/rollback-with-saga/practice/Workflow/Models/Bill.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace TemporalioSagaPattern.Practice.Workflow.Models;

public record Bill(
int CustomerId,
string OrderNumber,
string Description,
int Amount);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace TemporalioSagaPattern.Practice.Workflow.Models;

public record Customer(
int CustomerId,
string Name,
string Phone,
string CreditCardNumber,
string Email = "");
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace TemporalioSagaPattern.Practice.Workflow.Models;

public record Distance(int Kilometers = 0);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace TemporalioSagaPattern.Practice.Workflow.Models;

public record OrderConfirmation(
string OrderNumber,
string Status,
string ConfirmationNumber,
long BillingTimestamp,
int Amount);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace TemporalioSagaPattern.Practice.Workflow.Models;

public record Pizza(
string Description,
int Price);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace TemporalioSagaPattern.Practice.Workflow;

using System.Collections.ObjectModel;
using TemporalioSagaPattern.Practice.Workflow.Models;

public record PizzaOrder(
string OrderNumber,
Customer Customer,
Collection<Pizza> Items,
Address Address,
bool IsDelivery = false);
Loading

0 comments on commit 1bb7c00

Please sign in to comment.