Azure Durable Functions offer an excellent way for you to implement stateful workflows and orchestrations in the cloud. In this article, we will guide you through 2 implementation scenarios while focusing on unit testing.
When Should You Use Durable Functions?
Regular Azure functions are a perfect choice for running stateless logic. However, there are situations when you need to handle more complex workflows, such as those requiring manual approval, chaining multiple functions together, or performing polling. You can explore other use cases in the official Azure Docs.
Understanding Generator Functions
Generator functions are a special type of function that can be paused and resumed during execution. In languages like JavaScript, generator functions use a unique syntax (function*
) and the yield
keyword to control value production.
For more detailed information on generator functions, check out this article.
Durable Functions leverage generator functions as orchestrator functions, particularly in scenarios involving asynchronous tasks.
Implementing Polling via a Monitor Pattern
In this scenario, we will create an API that transforms an asynchronous operation into a synchronous one.
For this example, we will be using the v4 programming model. You can find details on migrating from v3 to v4 in this article.
To achieve this, we will use a durable function that uses an Azure activity function to continuously monitor an operation’s status until it completes (or the orchestrator client times out).
export const monitorOrchestrator: OrchestrationHandler = function* (
context: OrchestrationContext
) {
let polledTimes = 0;
// 1. Infinite loop until completion
while (true) {
// 2. Invoke activity function to check status
const status = yield context.df.callActivity(statusCheckActivityName);
polledTimes++;
if (status === "DONE") {
// 3. Break out of the loop once it's completed
break;
}
const deadline = DateTime.fromJSDate(context.df.currentUtcDateTime, {
zone: "utc",
}).plus({ seconds: 5 });
// 4. While not completed pause the execution for 5 seconds
yield context.df.createTimer(deadline.toJSDate());
}
return `Activity Completed, polled ${polledTimes} times`;
};
Here’s how it works:
-
We employ an infinite loop since we are polling until completion or timeout. This loop is eventually exited once the task is completed.
-
We use an activity function to check the status. This is the first
yield
statement. The orchestrator function pauses execution here until a value is yielded by the status check function. -
If the status is
DONE
, we break out of the loop and complete the orchestration. -
While the status is not
DONE
and loop continues, we create a timer and useyield
to pause execution until the timer elapses.
Unit Testing of the Monitor Orchestrator Function
To test the orchestrator function, call the monitorOrchestrator
function with a mock OrchestrationContext
. This provides an instance of a Generator
that you can use to execute the business logic by calling the next()
method.
describe("monitor orchestrator", () => {
it("polls 2 times until status is DONE", () => {
const mockContext: OrchestrationContext = {
df: {
callActivity: jest.fn(),
currentUtcDateTime: Date.now(),
createTimer: jest.fn()
},
} as unknown as OrchestrationContext;
// 1. creates the generator function
const generator = monitorOrchestrator(mockContext);
let result: IteratorResult
// 2. runs until the first yield
result = generator.next()
expect(result.done).toBe(false)
// 3. yields the status check value to be PENDING
result = generator.next('PENDING')
expect(result.done).toBe(false)
// 4. yields the timer
result = generator.next()
expect(result.done).toBe(false)
// 5. yields the status check value to be DONE
result = generator.next('DONE')
expect(result.done).toBe(true)
expect(result.value).toEqual('Activity Completed, polled 2 times')
});
});
Let’s walk through the steps in the test and how they execute different parts of the generator function:
Step 1: First, create the generator function. Note that the function code hasn’t been executed yet.
Step 2: This step executes the function code until it reaches the first yield
statement. In this case, it’s the first invocation of the status check activity.
Step 3: Here, call next()
on the generator, passing a value of PENDING
. This resumes execution from the previous step, giving status
a yielded value of PENDING
. The function continues until it encounters the next yield
statement, which is the createTimer()
. The timer is mocked in this test, but will pause the execution for 5 seconds in production.
Step 4: To resume with the function’s execution, call next()
again on the generator, this time without parameters, as there’s no need to yield any values from createTimer()
. The execution will pause again at the yield
statement of callActivity()
at the top of the while loop. Note that the value of status
remains PENDING
from the previous iteration.
Step 5: Now, call next()
with a yielded value of DONE
. This makes the function break out of the loop and finishes the execution. This completes the test, passing the final assertion.
Implementing Parallel Function Execution
In this scenario, we have an orchestrator that initiates multiple tasks running in parallel. Each task doubles the input value and returns it as a result. The orchestrator then pauses until all tasks are completed, adds their results, and returns the sum.
export const parallelOrchestrator: OrchestrationHandler = function* (
context: OrchestrationContext
) {
const tasks: Task[] = [];
// 1. Start all parallel activities
for (let i = 0; i < 5; i++) {
tasks.push(context.df.callActivity(doubleInputActivityName, i));
}
// 2. Wait for all parallel activities to complete
const results = yield context.df.Task.all(tasks);
// 3. Process the results
const total = results.reduce((val, acc) => (acc += val));
return `The sum is ${total}`;
};
Here’s the breakdown:
-
The orchestrator starts 5 tasks by calling
callActivity()
in a loop, assigning each of them an initial input from 0 to 4. Notice that theyield
keyword is not used when starting the tasks, allowing them to run in parallel. -
We pass the array of tasks to
Task.all()
and useyield
on it. This pauses the durable function’s execution until all tasks are completed. Their results are assigned to theresults
variable, similar to howPromise.all()
works. -
Once all tasks are completed, the sum of the results is calculated and stored in
total
, which is returned later.
Unit Testing of Parallel Function Execution
To test this scenario, follow a similar approach as in the previous example. Create the generator function and use next()
to provide values to the yield statements. In this case, there’s only one yield
statement that pauses the execution until all tasks are completed.
describe("parallel orchestrator", () => {
it("calculates sum of all parallel activities results", () => {
const mockContext: OrchestrationContext = {
df: {
callActivity: jest.fn(),
Task: {
all: jest.fn(),
},
},
} as unknown as OrchestrationContext;
const generator = parallelOrchestrator(mockContext);
// 1. Runs until the Task.all() yield
generator.next();
expect(mockContext.df.callActivity).toHaveBeenCalledTimes(5);
// 2. Yields Task.all() with an array of results
const result = generator.next([1, 2, 3, 4, 5]);
expect(result.value).toEqual("The sum is 15");
expect(result.done).toBe(true);
});
});
Step 1: After creating the generator function, this step executes the function code until it reaches the first yield
statement, which pauses until all tasks are complete.
Step 2: Here, call next()
again to yield the return values of all 5 tasks, which are [1, 2, 3, 4, 5]
. This array is used to calculate the sum, resulting in the final output string, “The sum is 15”.
Code Repository
You can find all the code used in this article on GitHub.
Now, you’re well-equipped to implement and test Azure Durable Functions in TypeScript. Happy coding!