Getting Started
Architecture
NServiceBus
Transports
Persistence
ServiceInsight
ServicePulse
ServiceControl
Monitoring
Samples

NServiceBus sagas: Timeouts

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.

Do I REALLY want to buy this?

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 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.

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.cs 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>. 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<PlaceOrder> 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.

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<BuyersRemorseIsOver>:

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<CancelOrder>. 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.

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.

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
  • If a CancelOrder command is sent from ClientUI to Sales before the timeout period, the order will be cancelled by marking the BuyersRemorsePolicy 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.