Being able to model the concept of time as part of a long-running process is incredibly powerful. Batch jobs are a feeble attempt at this. They fail at handling things in real-time and make every instance of a long-running process dependent on every other instance. What if the batch job fails?
For more on the difficulties associated with batch jobs, see Death to the Batch Job.
In addition to sending and publishing messages, NServiceBus can also delay messages. Delayed delivery effectively provides the ability to send messages into the future, a feature used by Saga Timeouts.
There's no need to write batch jobs to query data every night. Instead, each instance is able to manage time in its own workflow, setting virtual alarm clocks to do something.
The delivery of a delayed message is not guaranteed to occur at the specified point in time. It may be delayed further if the system is very busy at that time.
The use cases for saga timeouts are too numerous to count. So, in this tutorial we will focus on implementing the buyer's remorse pattern. In this pattern, customers that purchased something are able to cancel their order within a certain amount of time after it was placed. This is an important software pattern that pops up in non-retail domains as well. For example, Gmail uses the same pattern for their Undo Send feature.
With the buyer's remorse pattern, the purchase is kept in a holding state until after a defined delay. The order isn't really sent until the timeout has expired.
Exercise
In this tutorial, we'll model the delay period using a saga timeout. We'll change the existing project so that when the Sales endpoint receives the PlaceOrder
command, we don't instantly publish the OrderPlaced
event. Instead, we'll store the order state in the saga and set a timeout and do that in the future. When the timeout is due, we'll publish the OrderPlaced
event, unless we've received a CancelOrder
command in the meantime.
What if I didn't do the previous tutorial?
No problem! You can get started learning sagas with the completed solution from the previous lesson:
The ClientUI, Sales, Billing, and Shipping projects define endpoints that communicate with each other using messages. The ClientUI endpoint mimics a web application and is the entry point to the system. Sales, Billing, and Shipping contain business logic related to processing, fulfilling, and shipping orders. Each endpoint references the relevant .Messages assembly, which contains the classes that define the messages exchanged in our system. To see how to start building this system from scratch, check out the NServiceBus step-by-step tutorial.
This tutorial uses NServiceBus version 9, .NET 8, and assumes an up-to-date installation of Visual Studio 2022.
Saga storage
Saga state must be persisted somewhere. There are various persistence options but for this tutorial the LearningPersistence
is used. This is a simple persistence option for educational purposes and is not suited for production use.
In the Sales project, open Program.
and add the following configuration setting:
var persistence = endpointConfiguration.UsePersistence<LearningPersistence>();
BuyersRemorsePolicy saga
In the Sales project, create a new class called BuyersRemorsePolicy
and add the following code:
#pragma warning disable CS9113 // Parameter is unread.
class BuyersRemorsePolicy(ILogger<BuyersRemorsePolicy> logger) : Saga<BuyersRemorseData>
#pragma warning restore CS9113 // Parameter is unread.
{
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<BuyersRemorseData> mapper)
{
// TO BE IMPLEMENTED
}
}
public class BuyersRemorseData : ContainSagaData
{
public string OrderId { get; set; }
}
The policy inherits from Saga
. BuyersRemorseData
represents the saga's state by inheriting from ContainsSagaData
.
The ClientUI already sends a PlaceOrder
command to the Sales endpoint. This command is perfect to start the buyer's remorse saga. Let's implement the IAmStartedByMessages
interface in the saga:
class BuyersRemorsePolicy(ILogger<BuyersRemorsePolicy> logger) : Saga<BuyersRemorseData>,
IAmStartedByMessages<PlaceOrder>
{
public Task Handle(PlaceOrder message, IMessageHandlerContext context)
{
logger.LogInformation("Received PlaceOrder, OrderId = {OrderId}", message.OrderId);
Data.OrderId = message.OrderId;
return Task.CompletedTask;
}
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<BuyersRemorseData> mapper)
{
// TO BE IMPLEMENTED
}
}
Next, we need a way to map messages to sagas so we know which saga instances a particular message belongs to. We do this with the ConfigureHowToFindSaga
method and, in this case, we already have a natural mapping field: OrderId
.
#pragma warning disable CS9113 // Parameter is unread.
class BuyersRemorsePolicy(ILogger<BuyersRemorsePolicy> logger) : Saga<BuyersRemorseData>,
#pragma warning restore CS9113 // Parameter is unread.
IAmStartedByMessages<PlaceOrder>
{
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<BuyersRemorseData> mapper)
{
mapper.MapSaga(saga => saga.OrderId)
.ToMessage<PlaceOrder>(message => message.OrderId);
}
public Task Handle(PlaceOrder message, IMessageHandlerContext context)
{
//To be replaced with business code
return Task.CompletedTask;
}
}
Now let's implement our policy.
Timeout request and handling
Our next step is to tell our BuyersRemorsePolicy
to schedule a message to tell us when the buyer's remorse period is over. We do this with the RequestTimeout
method. Modify the Handle
method of BuyersRemorsePolicy
as follows:
public async Task Handle(PlaceOrder message, IMessageHandlerContext context)
{
logger.LogInformation("Received PlaceOrder, OrderId = {OrderId}", message.OrderId);
Data.OrderId = message.OrderId;
logger.LogInformation("Starting cool down period for order #{OrderId}.", Data.OrderId);
await RequestTimeout(context, TimeSpan.FromSeconds(20), new BuyersRemorseIsOver());
}
Besides the context
, the RequestTimeout
method has two interesting parameters. One is the TimeSpan
which tells us how long to wait before sending our timeout message. In this case, it's 20 seconds.
This tutorial uses 20 seconds as a timeout value for simplicity. In production, a business enforced rule should determine the length of this period.
Instead of a TimeSpan
, we could provide a DateTime
instance, such as DateTime.
. When using this format, remember that local time is affected by Daylight Savings Time (DST) changes, so use UTC dates instead to avoid DST conversion issues.
The other interesting parameter is the message that will be sent when the timeout elapses. In this case, we are providing an instance of BuyersRemorseIsOver
, a class which is not yet defined. Let's define it now. You can put it in the same file as our saga and leave it as an empty class:
class BuyersRemorsePolicy : Saga<BuyersRemorseData>,
IAmStartedByMessages<PlaceOrder>
{
// ...
}
class BuyersRemorseIsOver
{
}
This class is a message like any other message (e.g. OrderPlaced
). But it's specific to our saga so we'll keep it here with the rest of the code. If we needed more data, we could add properties to it to be included with the message.
The effect of these changes is that in 20 seconds, we will send a BuyersRemorseIsOver
message back to the saga. Next, we'll see what to do with this message when we receive it.
Timeout handling
Handling a timeout method is similar to how other handlers work. But instead of implementing a Handle
method, we implement a Timeout
method by implementing IHandleTimeouts
:
class BuyersRemorsePolicy(ILogger<BuyersRemorsePolicy> logger) : Saga<BuyersRemorseData>,
IHandleTimeouts<BuyersRemorseIsOver>
{
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<BuyersRemorseData> mapper)
{
//Omitted for clarity
}
public async Task Timeout(BuyersRemorseIsOver timeout, IMessageHandlerContext context)
{
logger.LogInformation("Cooling down period for order #{OrderId} has elapsed.", Data.OrderId);
var orderPlaced = new OrderPlaced
{
OrderId = Data.OrderId
};
await context.Publish(orderPlaced);
MarkAsComplete();
}
}
The code in the Timeout
method is business logic; stuff that is supposed to happen when an order is placed. When we're done, we publish an OrderPlaced
event to notify any subscribers that something important has occurred. Remember, our ShippingPolicy
saga still needs to know that an order has been placed so it can be shipped.
The last line of the method is a call to the MarkAsComplete
method. This is important because it tells the saga instance that it's finished. Any further messages to this instance will be ignored because there is no further work to be done for the saga. We'll return to this concept in the next section when handling cancellation.
We now have a working buyer's remorse policy so we don't need our existing PlaceOrderhandler
. Delete this class from the Sales project.
But it's not much of a buyer's remorse policy if we can't cancel the order. Let's do that now.
Order cancellation
As you might expect by now, cancelling an order is done by sending a command and handling it. First, define the CancelOrder
command in the Sales.Messages* project:
public class CancelOrder
: ICommand
{
public string OrderId { get; set; }
}
We handle CancelOrder
in the BuyersRemorsePolicy
saga by implementing IHandleMessages
. We also need to tell the saga how to map a CancelOrder
command to a saga instance which we can do with the OrderId
property, just as we did with the PlaceOrder
command:
class BuyersRemorsePolicy(ILogger<BuyersRemorsePolicy> logger) : Saga<BuyersRemorseData>,
IAmStartedByMessages<PlaceOrder>,
IHandleMessages<CancelOrder>,
IHandleTimeouts<BuyersRemorseIsOver>
{
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<BuyersRemorseData> mapper)
{
mapper.MapSaga(saga => saga.OrderId)
.ToMessage<PlaceOrder>(message => message.OrderId)
.ToMessage<CancelOrder>(message => message.OrderId);
}
public Task Handle(CancelOrder message, IMessageHandlerContext context)
{
logger.LogInformation("Order #{OrderId} was cancelled.", message.OrderId);
//TODO: Possibly publish an OrderCancelled event?
MarkAsComplete();
return Task.CompletedTask;
}
}
The Handle
method is very similar to the saga's Timeout
method. We log some information, execute some business logic, then mark the saga complete. This effectively cancels any outstanding timeouts currently in place for the saga. Remember, by calling MarkAsComplete
, we tell this saga instance that there is no further work to be performed.
If the system is busy, the cancellation message may be processed after the timeout message, even though the cancellation message was sent before the timeout message was due. The shorter the timeout duration is, the higher the probability is of this occurring.
Consider what happens when the buyer's remorse period has ended. The saga has been marked complete but maybe the Cancel button still appears on the user's screen and they click it. Assuming a CancelOrder
command is fired, nothing will happen. The saga instance is already complete so the message is discarded. In effect, we can't cancel an order that has already been placed. Similarly, we can't complete an order that has already been processed. MarkAsComplete
handles both of these scenarios for us.
Technically, it's not true that nothing will happen when a saga message is received after the instance has completed. We can implement the IHandleSagaNotFound
interface to perform logic in these scenarios. For example, we could inform the customer that the buyer's remorse period has ended so the order can no longer be cancelled.
Although we don't show it here, we may want to send an OrderCancelled
event in the Handle
method to indicate that an order has been cancelled. In this sample, we have no business process for cancelled orders, so we don't fire the event.
Finally, let's update the UI so that our customers can take advantage of our buyer's remorse policy.
Allow the UI to cancel orders
Now we need to modify ClientUI to send a CancelOrder
command. First, we define the routing for the command in the Main
method of the Program
class:
routing.RouteToEndpoint(typeof(CancelOrder), "Sales");
To allow users to cancel orders, we'll modify the ClientUI input loop in the InputLoopService
class to:
- store the ID of the sent order
- accept another command,
cancel
, that uses the previously stored ID to cancel the sent order
The new input loop looks like the following:
var lastOrder = string.Empty;
while (true)
{
Console.WriteLine("Press 'P' to place an order, 'C' to cancel an order, or 'Q' to quit.");
var key = Console.ReadKey();
Console.WriteLine();
switch (key.Key)
{
case ConsoleKey.P:
// Instantiate the command
var command = new PlaceOrder
{
OrderId = Guid.NewGuid().ToString()
};
// Send the command
Console.WriteLine($"Sending PlaceOrder command, OrderId = {command.OrderId}");
await endpointInstance.Send(command);
lastOrder = command.OrderId; // Store order identifier to cancel if needed.
break;
case ConsoleKey.C:
var cancelCommand = new CancelOrder
{
OrderId = lastOrder
};
await endpointInstance.Send(cancelCommand);
Console.WriteLine($"Sent a CancelOrder command, {cancelCommand.OrderId}");
break;
case ConsoleKey.Q:
return;
default:
Console.WriteLine("Unknown input. Please try again.");
break;
}
}
We now have a working buyer's remorse policy!
Summary
At this point, the solution is ready for placing and cancelling orders. Press F5 and test that you can place and cancel (within 20 seconds) orders. If everything is working, here's what will happen:
- A
PlaceOrder
command is delivered from ClientUI to Sales - Sales triggers a 20 second timeout and logs the information
- When the timeout expires:
- an
OrderPlaced
event is published by Sales - the
BuyersRemorsePolicy
saga is marked as completed
- an
- If a
CancelOrder
command is sent from ClientUI to Sales before the timeout period, the order will be cancelled by marking theBuyersRemorsePolicy
saga as completed
In the next lesson, we'll see how we can keep our solution robust even if we integrate with third-party services.