Human Interaction - Part 6 - Azure Durable Functions

2022, Sep 27

The Human Interaction pattern enables, as the name suggests, human interaction in orchestrators. Automated processes might require some human intervention, and this is the pattern for scenarios like that.

This is a series where I am explaining the components of durable functions1 and the different application patterns of implementation.

Check the PlayGoKids repository for this article demo.

Understanding the Flow

Flow

In this flow, the orchestration starts an activity that requires some human interaction, the orchestrator then waits for the interaction that comes from an external event, which is basically represented by an HTTP endpoint exposed by the orchestrator. To prevent that human interaction is missed or delayed, timeouts are applied in this pattern.

This example shows really well how to adopt the pattern. It is a classic example of an approval process that is started, and in case the human interaction takes too long or doesn't occur, the orchestrator escalates the request.

The HumanInteraction_HttpStart is the HTTP Trigger.

Starter Function (aka DurableClient)

[FunctionName($"{nameof(HumanInteraction)}_HttpStart")]
public async Task<HttpResponseMessage> HttpStart(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestMessage req,
    [DurableClient] IDurableOrchestrationClient starter,
    ILogger log)
{
    var expenseClaim = await req.Content.ReadAsAsync<ExpenseClaim>();

    var instanceId = await starter.StartNewAsync<ExpenseClaim>(nameof(Constants.RunOrchestrator), expenseClaim);

    log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

    return starter.CreateCheckStatusResponse(req, instanceId);
}

The ExpenseClaim holds the expense details:

public class ExpenseClaim
{
    public string Description { get; set; }
    public decimal Cost { get; set; }
}

This HTTPTrigger request is used to trigger the approval workflow:

curl --location --request POST 'http://localhost:7053/api/HumanInteraction_HttpStart' \
--header 'Content-Type: application/json' \
--data-raw '{
    "description": "Phone bill",
    "cost": 35.00
}'

Orchestrator Function

Looking at the Orchestrator, it receives the ExpenseClaim, which is then raised for approval on the activity RunRequestApproval. The timeout is then created using a Timer with a due time.

From this moment, through the External Event endpoint, approval occurs. This endpoint is automatically exposed when the Orchestrator starts:

External-Event

If approved it runs the activity RunProcessApproval, otherwise, it runs the activity RunEscalation.

public class Orchestrator
{
    [FunctionName(nameof(Constants.RunOrchestrator))]
        public async Task RunOrchestrator(
            [OrchestrationTrigger] IDurableOrchestrationContext context, ILogger log)
    {
        var expense = context.GetInput<ExpenseClaim>();

        await context.CallActivityAsync<ExpenseClaim>(nameof(Constants.RunRequestApproval), expense);

        using var timeoutCts = new CancellationTokenSource();
        var dueTime = context.CurrentUtcDateTime.AddHours(Constants.Timeout);
        var durableTimeout = context.CreateTimer(dueTime, timeoutCts.Token);

        var approvalEvent = context.WaitForExternalEvent<bool>(Constants.WaitForExternalApprovalEvent);
        if (approvalEvent == await Task.WhenAny(approvalEvent, durableTimeout))
        {
            timeoutCts.Cancel();
            await context.CallActivityAsync<bool>(Constants.RunProcessApproval, approvalEvent.Result);
        }
        else
        {
            await context.CallActivityAsync(Constants.RunEscalation, null);
        }
    }
}

Activity Functions

The activities in this demo are basically placeholders for Real World actions:

public class Activity
{
    private readonly INotifier _notifier;
    private readonly ILogger<Activity> _logger;

    public Activity(INotifier notifier, ILogger<Activity> logger)
    {
        _notifier = notifier;
        _logger = logger;
    }

    [FunctionName(nameof(Constants.RunRequestApproval))]
    public void RunRequestApproval([ActivityTrigger] ExpenseClaim expenseClaim)
    {
        _logger.LogInformation($"{nameof(RunRequestApproval)}");

        // TODO: send notification
        _notifier.Notify($"{expenseClaim.Description}, ${expenseClaim.Cost}");
    }

    [FunctionName(nameof(Constants.RunProcessApproval))]
    public void RunProcessApproval([ActivityTrigger] bool approved)
    {
        _logger.LogInformation($"{nameof(RunProcessApproval)} - Approved:{approved}");

        // TODO: process approval
    }

    [FunctionName(nameof(Constants.RunEscalation))]
    public void RunEscalation([ActivityTrigger] IDurableActivityContext context)
    {
        _logger.LogInformation($"{nameof(RunEscalation)}");

        // TODO: escalate
        _notifier.Notify("escalated");
    }
}

For the external event, the CURL request is used to raise an event with the approval response. The URL below is output when the Orchestrator is started, as explained before:

curl --location --request POST 'http://localhost:7053/runtime/webhooks/durabletask/instances/{instanceId}/raiseEvent/Approval?taskHub=TestHubName&connection=Storage&code={code}' \
--header 'Content-Type: application/json' \
--data-raw '"true"'

The Notifier is just a dummy that represents a notifier service:

public class Notifier : INotifier
{
    public void Notify(string message)
    {
        // TODO: send notification
    }
}

This is an example of how to use the Human Interaction pattern, used in an approval process.

In the next article of the series let's check out how to use the Aggregator pattern.

Full Series

Major components

Function chaining

Fan out/fan in

Async HTTP APIs

Monitor

Human interaction

Aggregator (stateful entities)


  1. Azure Durable Functions link