A Tour around Durable Functions for Java

Published Jun 20 2022 03:41 PM 1,328 Views
Microsoft

If you haven't already heard, we recently announced the public preview of Durable Functions for Java. With this release, Durable Functions now supports .NET, Node.js, Python, PowerShell, and finally Java running on Azure Functions. In this post, I'll give a brief tour of Durable Functions for Java to highlight some of the capabilities and differences compared to other languages.

 

If this is your first time learning about Azure Durable Functions, I definitely recommend reading through the Durable Functions overview content first.

 

Orchestrator Functions

Before diving too deep into design discussions, let's take a look at the classic "Hello, cities!" orchestrator function, written in Java.

 

/**
* This is the orchestrator function, which can schedule activity
* functions, create durable timers, or wait for external events
* in a way that's completely fault-tolerant. The
* OrchestrationRunner.loadAndRun() static method is used to take
* the function input and execute the orchestrator logic.
*/
@FunctionName("HelloCities")
public String helloCitiesOrchestrator(@DurableOrchestrationTrigger(name = "runtimeState") String runtimeState) {
    return OrchestrationRunner.loadAndRun(runtimeState, ctx -> {
        String result = "";
        result += ctx.callActivity("SayHello", "Tokyo", String.class).await() + ", ";
        result += ctx.callActivity("SayHello", "London", String.class).await() + ", ";
        result += ctx.callActivity("SayHello", "Seattle", String.class).await();
        return result;
    });
}

 

As you can hopefully tell, this orchestrator function calls three "SayHello" activity functions in a sequence, concatenates their outputs, and returns the result. The output of this orchestration is always "Hello Tokyo!, Hello London!, Hello Seattle!".

 

Let's break down this code a bit to better understand what's going on.

 

Orchestrator function scaffolding

There's a little bit of scaffolding involved when writing orchestrator functions in this initial preview. Here's the basic formula:

  • Use the @DurableOrchestrationTrigger annotation to declare an orchestrator function.
  • Both the trigger input and the function output must be String.
  • The function must call the static OrchestrationRunner.loadAndRun method, which is part of the com.microsoft.durabletask package.
  • The first parameter to OrchestrationRunner.loadAndRun is the trigger's input value (which happens to be a base64-encoded protobuf payload).
  • The second parameter is the actual orchestrator logic as a lambda function.
  • The return value of OrchestrationRunner.loadAndRun must be used as the function's return value.

The goal is that most of this scaffolding will be removed before the GA (General Availability) release of Durable Functions for Java, but until then it's best to just copy/paste the above example when creating new orchestrator functions.

 

The orchestrator lambda function must take a single parameter of type TaskOrchestrationContext. This is the ctx parameter in the above example. Using that, you can get the orchestration input, call activities, schedule durable timers, wait for external events, etc.

 

Scheduling tasks

In all languages that support Durable Functions, orchestrator functions schedule some "tasks", wait for them to complete, and then move onto the next step. This is no different in Java. Methods such as callActivity(...) return a Task<V> (where V is the return type of the task). To block and wait for a task to complete, you use the Task.await() method.

 

// Call an activity that takes an input and returns a String
String result = ctx.callActivity(
    "SayHello" /* activity function name */,
    "Tokyo" /* activity function input argument */,
    String.class /* activity function return type */
).await();

// Block the orchestration for a day
ctx.createTimer(Duration.ofDays(1)).await();

// Call an activity with no input that returns a Double
Double quote = ctx.callActivity("GetQuote", Double.class).await();

 

Note that the String.class parameter is used to determine how to deserialize the return value. If no return value is required, you can use Void.class or an overload that doesn't take this parameter.

 

Awaiting tasks

In Serverless FaaS, blocking on external resources is an anti-pattern because you normally have to pay for the time spent waiting. However, in Durable Functions, we avoid this by terminating the function execution whenever an orchestrator function gets blocked. The exact mechanism for silently terminating the execution is different for each language, but for Java, we do this by throwing a custom Throwable called OrchestratorBlockedEvent the first time Task.await() is called for a particular Task (please don't catch it!). Once the awaited task is complete, the orchestrator function will automatically execute a second time, this time returning a value from the call to .await(), and then move to the next step in the orchestrator function.

 

Executing tasks concurrently

The above examples show how to execute tasks in a sequence, but it's also possible to execute tasks in parallel. The following example shows how you can execute a dynamic number of tasks in parallel, wait for them to complete, and then run an aggregate function on the results.

 

// Get the list of work-items to process
List<?> batch = ctx.callActivity("GetWorkBatch", List.class).await();

// Schedule each task to run in parallel
List<Task<Integer>> parallelTasks = batch.stream()
        .map(item -> ctx.callActivity("ProcessItem", item, Integer.class))
        .collect(Collectors.toList());

// Wait for all tasks to complete, then return the
// aggregated sum of the results
List<Integer> results = ctx.allOf(parallelTasks).await();
return results.stream().reduce(0, Integer::sum);

 

The ctx.allOf(parallelTasks).await() expression causes the orchestrator function to block and wait for all of the parallel tasks to complete. Behind the scenes, these tasks will be distributed across all compute instances and Azure Functions will know to add more compute instances as necessary to maximize parallelism and throughput.

 

Waiting for external events

You can also use tasks to block your orchestration until an external signal is received. For example, if you're implementing an approval workflow, you may need a step that waits for a manager to issue an explicit approval before moving on. We call these signals "external events".

 

// Wait 72 hours for a signal named "ApprovalEvent" which will
// contain a Boolean payload
Duration timeout = Duration.ofHours(72);
boolean approved = ctx.waitForExternalEvent("ApprovalEvent", timeout, boolean.class).await();

 

You can also wait for multiple events in parallel and process them one at a time, as they arrive. For example, suppose your orchestration represents a game loop that waits for one of several known commands, as shown in the following example.

 

Task<Void> command1 = ctx.waitForExternalEvent("Command1");
Task<Void> command2 = ctx.waitForExternalEvent("Command2");
Task<Void> command3 = ctx.waitForExternalEvent("Command3");

// wait for any of the three events to be delivered
Task<?> received = ctx.anyOf(event1, event2, event3).await();
if (received == command1) {
    // execute some command1 behavior...
} else if (received == command2) {
    // execute some command2 behavior...
} else if (received == command3) {
    // execute some command3 behavior...
}

 

External events can be sent to a waiting orchestration by either using the built-in HTTP webhooks or by using the Java client SDK, as shown in the following example HTTP-triggered function:

 

@FunctionName("RaiseApprovalEvent")
public void raiseApprovalEventToOrchestration(
        @HttpTrigger(name = "req", methods = {HttpMethod.POST}) String instanceId,
        @DurableClientInput(name = "durableContext") DurableClientContext durableContext) {
    // assumes the orchestration is expecting an external event
    // named "ApprovalEvent" will a Boolean payload
    DurableTaskClient client = durableContext.getClient();
    client.raiseEvent(instanceId, "ApprovalEvent", true);
}

 

Scheduling orchestrations

I've shown you how to implement orchestrator functions but not yet how to schedule them. There are three ways to invoke orchestrator functions:

  1. Using the built-in HTTP webhook API
  2. Using the Durable Task client SDK
  3. Using the orchestration context API (sub-orchestrations!)

The most common mechanism for scheduling orchestrations is #2 (using the Durable Task client SDK) from inside a function. Here's an example.

 

/**
* This HTTP-triggered function starts the orchestration.
*/
@FunctionName("StartHelloCities")
public HttpResponseMessage startHelloCities(
        @HttpTrigger(name = "req", methods = {HttpMethod.POST}) HttpRequestMessage<?> req,
        @DurableClientInput(name = "durableContext") DurableClientContext durableContext,
        ExecutionContext context) {

    DurableTaskClient client = durableContext.getClient();
    String instanceID = client.scheduleNewOrchestrationInstance("HelloCities");

    context.getLogger().info(String.format("Created new Java orchestration with ID '%s'", instanceId));

    // Return an HTTP 202 response for polling clients to
    // periodically check the status of the orchestration
    return durableContext.createCheckStatusResponse(req, instanceID);
}

 

You can also schedule an orchestration from within another orchestration. Orchestrations created in this way are referred to as sub-orchestrations. Since sub-orchestrations are just tasks, you can even schedule them in parallel, just like with activity functions. For example, suppose you have an IoT orchestration that needs to run a setup orchestration (called "RunSetup") for a set of devices, as in the following code snippet:

 

// get the list of device IDs
List<?> deviceIDs = ctx.getInput(List.class);

// Schedule each setup sub-orchestration to run in parallel
List<Task<Void>> parallelTasks = deviceIDs.stream()
        .map(device -> ctx.callSubOrchestrator("RunSetup", device))
        .collect(Collectors.toList());

ctx.allOf(parallelTasks).await();
// ...

 

Sub-orchestrations are especially useful if you want to break up large, complex orchestrations into smaller, reusable functions.

 

Automatic retries

Part of building out a reliable task orchestration is ensuring that individual tasks are resilient to downstream failures. The most common tactic for dealing with such failures is with retries. Activity and sub-orchestration tasks support declarative retry policies, as shown in the following code snippet:

 

// Make 3 attempts with 5 seconds between retries
final int maxAttempts = 3;
final Duration firstRetryInterval = Duration.ofSeconds(5);
RetryPolicy retryPolicy = new RetryPolicy(maxAttempts, firstRetryInterval);
TaskOptions options = new TaskOptions(retryPolicy);
ctx.callActivity("FlakeyFunction", options).await();

 

Sometimes you need to express a retry policy using business logic. Durable Functions for Java supports this too using custom retry handlers. A custom retry handler is an extension of your orchestrator function that runs when a particular task fails with an unhandled exception. Here's a code snippet that shows how to implement a custom retry handler:

 

RetryHandler retryHandler = retryCtx -> {
    // Don't retry anything that derives from RuntimeException
    FailureDetails failure = retryCtx.getLastFailure();
    if (failure.isCausedBy(RuntimeException.class)) {
        return false;
    }

    // Quit after N attempts
    return retryCtx.getLastAttemptNumber() < 3;
};

TaskOptions options = new TaskOptions(retryHandler);
try {
    ctx.callActivity("FlakeyActivity", options).await();
} catch (TaskFailedException ex) {
    // Case when the retry handler returns false...
}

 

Basic requirements

Durable Functions for Java has a few basic requirements to be aware of.

  • JDK 8 or higher (JDK 11 is recommended and used in most of our samples)
  • Azure Functions v3.x runtime or higher (v4.x is recommended)
  • Azure Functions extension bundles 4.x or higher

The requirement for extension bundles is easy to miss, so be really careful about that. Here's the host.json file you can use as a starting point.

 

{
  "version": "2.0",
  "logging": {
    "logLevel": {
      "DurableTask.AzureStorage": "Warning",
      "DurableTask.Core": "Warning"
    }
  },
  "extensions": {
    "durableTask": {
      "hubName": "JavaTestHub"
    }
  },
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview",
    "version": "[4.*, 5.0.0)"
  }
}

 

At the time of writing, the v4.x extension bundles are in preview, hence the Preview suffix in the id field. In due time, this bundle will graduate out of preview and the Preview suffix can be removed.

 

UPDATE: Be aware that there may be some compatibility issues with Preview bundles for some extensions. For example, at the time of writing, the Cosmos DB bindings are not compatible with Java function apps when using the v4.x extension bundles (source).

 

Required packages

In order to get the necessary classes and annotations, you'll need to add a reference to the durabletask-azure-functions package to your function app. Here's a snippet of the build.gradle file we use in the sample app.

 

plugins {
    id "com.microsoft.azure.azurefunctions" version "1.8.0"
}

apply plugin: 'java'
apply plugin: "com.microsoft.azure.azurefunctions"

dependencies {
    implementation 'com.microsoft.azure.functions:azure-functions-java-library:2.0.1'
    implementation 'com.microsoft:durabletask-azure-functions:1.0.0-beta.1'
}

 

You can expect that improvements will be made to this library over time, so be sure to regularly check for updates.

 

Concluding thoughts

Durable Functions and the Java support is open source and developed openly on GitHub. If you want to report issues or contribute, you can do so freely in the Durable Task Java repo at https://github.com/microsoft/durabletask-java. It's fully supported by Microsoft and represents our continued investment into both the Serverless and the Java ecosystems. We hope Java developers will love it and find themselves incredibly productive using it!

 

For me personally, this is my first time contributing to a Durable Functions language SDK since the original launch of Durable Functions in 2017. It's also my first time doing any coding in Java since college, when we were using JS2E 1.4! The process of (re)learning Java has been truly rewarding and the process of designing and building this SDK from the ground up with the Azure Functions team has really helped me appreciate the Java language, the tooling, and the overall ecosystem. I look forward to continuing to improve Durable Functions for Java so that it truly feels first-class.

 

If you're ready to get started with Durable Functions, please take a look at our quick start guide, which you can find at https://docs.microsoft.com/azure/azure-functions/durable/quickstart-java.

Co-Authors
Version history
Last update:
‎Jun 30 2022 01:21 PM
Updated by: