Data Imports in Optimizely: Part 5 - Use async where possible

.NET’s async functionality allows for asynchronous functionality in various code that does things such as I/O. This has the advantage of not blocking the thread during I/O, giving better scalability and performance. Use cases include database queries, reading from the filesystem, and making network requests. Async in .NET also allows for parallel execution of tasks delegating to the thread pool.

Optimizely does not support async for all of its APIs. Most notably, scheduled jobs are synchronous, as are the various data access interfaces such as IContentLoader and IContentRepository. It would certainly be nice if Optimizely would add async support for data access, but as of 2025 that is not supported.

However, there is still a use case for async code in Optimizely imports. Frequently an import is performing some I/O to read data for import. Additionally, if there are network calls as part of the import such as importing images into the Optimizely CMP DAM, then async is an important tool.

Async in Optimizely Scheduled Jobs

Optimizely scheduled jobs are notably synchronous, so how do we call async code from a synchronous context? That’s where the AsyncEx package comes in. AsyncEx is provided by the Nito.AsyncEx nuget package. AsyncEx provides an asynchronous context for running async code from within a synchronous context, where async code would otherwise not be possible to run.

Consider the following example:

using EPiServer.Scheduler;
using Nito.AsyncEx;

public class MyJob : ScheduledJobBase
{
    public override string Execute()
    {
        return AsyncContext.Run(async () =>
        {
            return await MyAsyncTask();
        });
    }
    
    public Task<string> MyAsyncTask()
    {
        return Task.FromResult("Job completed successfully!");
    }
}

In this example, we use AsyncContext.Run() and pass an async function to it. This allows us to run the async code in MyAsyncTask even though Execute is a synchronous method.

Stopping Async Code

Optimizely supports stopping scheduled jobs. It does this by calling the Stop() method on your scheduled job class. It is up to the implementation of the Stop() method to actually stop the code.

We can do this by leveraging cancellation tokens. To do this, we setup a CancellationTokenSource when starting our job, and then pass the cancellation token it provides to our processing method. Then, periodically in our job we check if the cancellation token has been cancelled, and abort processing if it has. To stop, in our Stop() method implementation we cancel the CancellationTokenSource which propagates down to our cancellation token.

using EPiServer.Scheduler;
using Nito.AsyncEx;

public class MyJob : ScheduledJobBase
{
    private CancellationTokenSource? _cancellationTokenSource;

    public override void Stop()
    {
        _cancellationTokenSource?.Cancel();
        IsStoppable = true;
    }

    public override string Execute()
    {
        _cancellationTokenSource = new CancellationTokenSource();

        return AsyncContext.Run(async () =>
        {
            return await MyAsyncTask(_cancellationTokenSource.Token);
        });
    }

    public async Task<string> MyAsyncTask(CancellationToken cancellationToken)
    {
        var itemsToProcess = await GetItemsAsync(cancellationToken);

        foreach (var itemToProcess in itemsToProcess)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                return "Job was cancelled.";
            }

            // do work
        }
    }
}

Note: We could also handle this by using exceptions and catching them in the Execute() method.

Running Multiple Tasks

.NET allows running multiple tasks at the same time using the Task. This is useful when there are multiple dependent calls that need to be made for one item in processing. Consider the following example:

using EPiServer.Scheduler;
using Nito.AsyncEx;

public class MyJob : ScheduledJobBase
{
    private CancellationTokenSource? _cancellationTokenSource;

    public MyJob() {
        IsStoppable = true;
    }
    
    public override void Stop()
    {
        _cancellationTokenSource?.Cancel();
    }

    public override string Execute()
    {
        _cancellationTokenSource = new CancellationTokenSource();

        return AsyncContext.Run(async () =>
        {
            return await MyAsyncTask(_cancellationTokenSource.Token);
        });
    }

    public async Task<string> MyAsyncTask(CancellationToken cancellationToken)
    {
        var itemsToProcess = await GetItemsAsync(cancellationToken);

        foreach (var itemToProcess in itemsToProcess)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                return "Job was cancelled.";
            }

            var tasks = new List<Task>();

            foreach (var subItem in itemsToProcess.Subitems)
            {
                tasks.Add(ProcessSubItemAsync(subItem, cancellationToken));
            }

            await Task.WhenAll(tasks);
        }
    }
}

In this example, we collect a list of Tasks based on another async method, and then await them all at the same time with Task.WhenAll(). Note that this doesn’t guarantee that the tasks are truly multithreaded, but rather that they are able to concurrently run multiple I/O operations without blocking waiting for the operation to complete.

Previous
Previous

Data Imports in Optimizely: Part 6 - Parallelize - if it makes sense

Next
Next

Data Imports in Optimizely: Part 4 - Only save when necessary