Durable Functions: Part 3 – Approve the Upload

All code for this series can be found here: https://github.com/jfarrell-examples/DurableFunctionExample

In Part 1 of this series, we explained what we were doing and why including the aspects of Event Driven Design we are hoping to leverage using Durable Functions (and indeed Azure Functions) for this task.

In Part 2, we build our file uploader that sent our file to blob storage and recorded a dummy entry in Azure Table Storage that will later hold our metadata. We also explained why we choose Azure Table Storage over Document DB (Cosmos default offering)

Here in Part 3, we will start to work with Durable Functions directly by triggering it based on the afore mentioned upload operation (event) and allowing its progression to be driven by a human rather than pure backend code. To that end, we will create an endpoint that enables a human to approve a file by its identifier which, advances the file through the workflow represented by the Durable Function.

Defining Our Workflow

Durable Function workflows are divided into two parts: The Orchestrator Client and the Orchestrator itself.

  • The Orchestrator Client is exactly what it sounds like, the client which launches the orchestrator. Its main responsibility is initializing the long running Orchestrator function and generating an instanceId which can be thought of as a workflow Id
  • The Orchestrator, as you might expect, represents our workflow in code with the stopping points and/or fan outs that will happens as a result of operations. Within this context you can start subworkflows if desired or (as we will show) wait for a custom event to allow advancement

To that end, I have below the code for the OrchestratorClient that I am using as part of this example.

[FunctionName("ApproveFile_Start")]
public static async Task HttpStart(
[BlobTrigger("files/{id}", Connection = "StorageAccountConnectionString")] Stream fileBlob,
string id,
[Table("metadata", "{id}", "{id}", Connection = "TableConnectionString")] FileMetadata metadata,
[Table("metadata", Connection = "TableConnectionString")] CloudTable metadataTable,
[DurableClient] IDurableOrchestrationClient starter,
ILogger log)
{
// Function input comes from the request content.
string instanceId = await starter.StartNewAsync("ProcessFileFlow", new ApprovalWorkflowData { TargetId = id });
metadata.WorkflowId = instanceId;
var replaceOperation = TableOperation.Replace(metadata);
var result = await metadataTable.ExecuteAsync(replaceOperation);
log.LogInformation($"Started orchestration with ID = '{instanceId}'.");
log.LogInformation("Flow started");
}
Approve Start Function (Github Gist)

First, I want to call attention to the definition block for this function. You can see a number of parameter, most of which have an attribute decoration. The one to focus on is the BlobTrigger as it does two things:

  • It ensures this functions is called whenever a new object is written to our files container in the storage account defined by our Connection. The use of these types of triggers is essential for achieving the event driven style we are after and which yield substantial benefit when used with Azure Functions
  • It defines the parameter id via its binding notation {id} through this, we can use this value in other parameters which feature binding (such as if we wanted to output a value to a Queue or something similar)

The Table attributes each perform a separate action:

  • The first parameter (FileMetadata) extracts from Azure Table Storage the row with the provided RowKey/PartitionKey combination (refer to Part 2 for how we stored this data). Notice the use of {id} here – this value is defined via the same notation used in the BlobTrigger parameter
  • The second parameter (CloudTable) brings forth a CloudTable reference to our Azure Storage Table. Table does not support an output operation, or at least not a straightforward one. So, I am using this approach to save the entity from the first parameter back to the table, once I update some values

What is most important for this sort of function is the DurableClient reference (Need this Nuget package). This is what we will use to start the action workflow.

Reference Line 11 of our code sample and the call to StartNewAsync. This literally starts an orchestrator to represent the workflow. It returns an InstanceId which we save back to our Azure Table Storage Entity. Why? We could technically have the user pass the InstanceId received from IDurableOrchestrationClient but, for this application, that would run contrary to the id they were given after file upload so, instead we choose to have them send us the file id, perform a look up so we can access the appropriate workflow instance, your mileage may vary.

Finally, since this method is pure backend there is no reason to return anything though you certainly could. In the documentation here Microsoft lays out a number of architectural patterns that make heavy use of the parallelism offered through Durable Functions.

Managing the Workflow

Noting the above code on Line 11 we actually name the function that we want to start, this function is expected to have one argument of type IDurableOrchestrationContext (Note Client vs Context) that is decorated with the OrchestratioinTrigger attribute. This denotes the method is triggered by a DurableClient starting a workflow with this given name (the name here is ProcessFileFlow).

The code for this workflow (at least the initial code) is shown below:

[FunctionName("ProcessFileFlow")]
public static async Task RunOrchestrator(
[OrchestrationTrigger] IDurableOrchestrationContext context,
ILogger log)
{
var uploadApprovedEvent = context.WaitForExternalEvent<bool>("UploadApproved");
await Task.WhenAny(uploadApprovedEvent);
log.LogInformation("File Ready");
}
view raw workflow1.cs hosted with ❤ by GitHub
Workflow Function – Part 1

I feel it is necessary to keep this function very simple and only contain code that represents steps in the flow or any necessary logic for branching. Any updates to the related info elements is kept in the functions themselves.

For this portion of our code base, I am indicating to the Orchestration Context that advancement to the next step can only occur when an external event called UploadApproved is received. This is, of course, an area that we could provide a split of even a time out concept (this so we dont have n number of workflows sitting waiting for an event that may never be coming).

To raise this event, we need to build a separate function (I will use an HttpTrigger) that can raise this event. Here is the code I choose to use:

[FunctionName("ApproveFileUpload")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "approve/{fileId}")] HttpRequest req,
[Table("metadata", "{fileId}", "{fileId}", Connection = "TableConnectionString")] FileMetadata fileMetadata,
[Table("metadata", Connection = "TableConnectionString")] CloudTable metadataTable,
[DurableClient] IDurableOrchestrationClient client,
ILogger log)
{
var instanceId = fileMetadata.WorkflowId;
fileMetadata.ApprovedForAnalysis = true;
var replaceOperation = TableOperation.Replace(fileMetadata);
await metadataTable.ExecuteAsync(replaceOperation);
await client.RaiseEventAsync(instanceId, "UploadApproved", fileMetadata.ApprovedForAnalysis);
return new AcceptedResult(string.Empty, fileMetadata.RowKey);
}
Upload Approve Http Function

Do observe that, as this an example, we are omitting a lot of functionality that would pertain to authentication and authorization to allow the UploadApprove action – as such this code should not be taken literally and used only to understand the concept we are driving towards.

Once again, we leverage bindings to simplify our code, mainly based on the fileId provided by the caller we can bring in the FileMetadata reference represented in our Azure Table Storage (we also bring in CloudTable so the afore mentioned entry can be updated to denote the file upload has been approved).

Using the IDurableOrchestrationClient injected into this function we can use the RaiseEventAsync method with the InstanceId extracted from the Azure Table Storage record to raise the UploadApproved event. Once this event is raised, our workflow advances.

Next Steps

Already we see the potential use cases for this approach, as the ability to combine workflow advancement with code based approaches makes our workflows even more dynamic and flexible.

In Part 4, we will close out the entire sample as we add two more approval steps to the workflow (one code driven and the other user driven) and then add a method to download the file.

I hope this was informative and has given you an idea of the potential durable functions hold. Once again, here is the complete code for reference: https://github.com/jfarrell-examples/DurableFunctionExample

6 thoughts on “Durable Functions: Part 3 – Approve the Upload

  1. Trying to follow along at home…. any chance you can add a “fake” local.settings.json? I’m getting an error about the table connection string, and I must be grabbing it from the wrong place.

    AccountEndpoint=https://blahblah.documents.azure.com:443/;AccountKey=l0ngenc0d3dst1ng==;

    Like

    • Greetings Joe,

      Apologies on the slowness of approving this, just bought my first house. To your request I have created this gist which I hope is helpful


      {
      "IsEncrypted": false,
      "Values": {
      "AzureWebJobsStorage": "DefaultEndpointsProtocol=https;AccountName=<some name>;AccountKey=<some key>;EndpointSuffix=core.windows.net",
      "AzureWebJobsTableStorageConnectionString": "DefaultEndpointsProtocol=https;AccountName=<some name>;AccountKey=<some key>;EndpointSuffix=core.windows.net",
      "FUNCTIONS_WORKER_RUNTIME": "dotnet",
      "StorageAccountConnectionString": "DefaultEndpointsProtocol=https;AccountName=<some name>;AccountKey=<some key>;EndpointSuffix=core.windows.net",
      "TableConnectionString": "DefaultEndpointsProtocol=https;AccountName=<some name>;AccountKey=<some key>;TableEndpoint=https://table-filemeta.table.cosmos.azure.com:443/;",
      "CognitiveServicesKey": "<some key>",
      "CognitiveServicesEndpoint": "https://<some name>.cognitiveservices.azure.com/"
      }
      }

      view raw

      settings.json

      hosted with ❤ by GitHub

      Cheers

      Like

Leave a comment