Exploring Sitecore Jobs

,

As a Sitecore developer you are likely familiar with Sitecore Jobs. Sitecore Jobs is a great way for starting and monitoring long-running tasks on a Sitecore webserver. In this post I will explore some ideas I have been exploring with regards to extending Sitecore Jobs a bit. It revolves around two topics – firstly, making the handling of job a bit easier and more streamlined in a large solution, and secondly to make it possible to return a result from a completed job.

Implementing a JobService

If you use Sitecore Jobs in a large solution, it might be a good idea to wrap the logic for starting and querying jobs into a centralized JobService. This will help you avoid having chunks of Sitecore Jobs related code scattered around the codebase and make the starting of new jobs easy and uniform. Below I have provided a simple implementation of a JobService, that will allow us to start and query jobs with minimal impact on the calling code:

namespace Services
{
    using Sitecore.Jobs.AsyncUI;
    using Sitecore.Jobs;
    using System;
    using System.Threading;
    using Sitecore;
    using Sitecore.Diagnostics;

    public class JobService
    {
        public string StartJob(
            string jobNamePrefix,
            Action action,
            bool singleJob = false,
            ThreadPriority priority = ThreadPriority.BelowNormal
        )
        {
            var jobName = GetJobName(jobNamePrefix, singleJob);
            if (DoJobExist(jobName))
                return jobName;
            var jobOptions = new DefaultJobOptions(
                jobName,
                typeof(JobService).Name,
                Context.Site.Name,
                this,
                nameof(RunAction),
                new object[] { action }
            )
            { Priority = priority };
            if (Context.User != null) 
                jobOptions.ContextUser = Context.User;
            JobManager.Start(jobOptions);
            return jobName;
        }

        private bool DoJobExist(string jobName)
        {
            return JobManager.GetJob(jobName) != null;
        }
        
        // Generate a unique job name
        private string GetJobName(
            string jobNamePrefix, 
            bool singleJob
        )
        {
            return string.Concat(
                jobNamePrefix,
                "_",
                (singleJob ? Guid.Empty : 
                Guid.NewGuid()).ToString());
        }

        public JobStatus Query(string jobName)
        {
            var job = JobManager.GetJob(jobName);
            if (job == null)
            {
                Log.Warn(
                    $"JobService: Unknown job queried: {jobName}",
                this);
                return null;
            }
            
            // Wrap the BaseJob into a JobStatus
            return new JobStatus(job);
        }

        private void RunAction(Action action)
        {
            if (JobContext.IsJob)
            {
                action.Invoke();
            }
        }
    }
}

Notice that the JobService will support running multiple jobs with identical names by postfixing the job name with a random GUID. This is controlled by the singleJob parameter in the StartJob method.

In a real-world scenario, the JobService could be provided via dependency injection, but in this blog post, I am simply creating a new JobService each time I need one.

Implementing a JobResult

While the JobService does not provide new functionality, you will notice that when querying a running or finished job, it wraps the resulting BaseJob in a custom JobStatus class.

While this is not necessary in simple scenarios, this will allow us to extend the JobStatus and control its serialization. To illustrate this, below is a JobStatus implementation, which support serialization of the JobStatus to a JSON ActionResult using Newtonsoft. This allows us to query running jobs via a REST API:

namespace Services
{
    using Newtonsoft.Json;
    using Sitecore.Abstractions;
    using System.Web.Mvc;

    public class JobStatus
    {
        public JobStatus(BaseJob job)
        {
            Status = job.Status.State.ToString();
            Priority = job.Options.Priority.ToString();
            JobName = job.Name;
            User = job.Options.ContextUser?.Name ?? string.Empty;
            Messages = job.Status.GetMessages();
            Processed = job.Status.Processed;
            IsDone = job.IsDone;
            IsFailed = job.Status.Failed;
        }

        public string JobName { get; set; }

        public string Status { get; set; }

        public string User { get; set; }

        public string Priority { get; set; }

        public string[] Messages { get; set; }

        public bool IsDone { get; set; }

        public bool IsFailed { get; set; }

        public long Processed { get; set; }
       
        // Convert job status to an action result
        public ActionResult ToActionResult()
        {
            var contentResult = new ContentResult();

            contentResult.ContentType = "application/json";
            contentResult.Content = 
                JsonConvert.SerializeObject(this);
            return contentResult;
        }
    }
}

Calling the JobService

With the JobService and JobStatus in place, we are now able to start jobs using this simple syntax – providing only the name of the job and an Action:

return new JobService().StartJob(
    "SomeWork",
    DoSomeWork,
);

The call above will return the name of the job (the provided job name postfixed with a GUID), which can then be used to query the job using the JobService.Query() method.

Returning a value from a job

One of the things I miss from Sitecore Jobs is the ability to return a result from a finished job. I have yet to find a good way to achieve this, but I have explored the idea of serializing the result into a special “result” message. And with the above JobService in place this is straight forward.

So let us imagine that we have a Sitecore Job doing some calculation and we want to return this simple object once the calculation has been done:

public class JobResult
{
    public string CalculationId { get; set; }
    public int CalculationSum { get; set; }
}

Updating the JobService

To start with, let us rewrite the JobService so that it now accepts a Func instead of an Action. The Func will return the result of our calculation (a JobResult), but the JobService will simply accept a Func<object>. This will work because the Func<T> is covariant with the T parameter – meaning that a Func<JobResult> is also Func<object>:

public string StartJob(
    string jobNamePrefix,
    Func<object> func,
    bool singleJob = false,
    ThreadPriority priority = ThreadPriority.BelowNormal
)
{
    var jobName = GetJobName(jobNamePrefix, singleJob);
    if (DoJobExist(jobName))
        return jobName;
    var jobOptions = new DefaultJobOptions(
        jobName,
        typeof(JobService).Name,
        Context.Site.Name,
        this,
        nameof(RunFunc),
        new object[] { func }
    )
    { Priority = priority };
    if (Context.User != null) 
        jobOptions.ContextUser = Context.User;
    JobManager.Start(jobOptions);
    return jobName;
}

Of cause we will also need to change the invoke method. I have renamed it from RunAction to RunFunc and implemented a serialization of the result of the method invocation. We will store the serialized object into a special message that we prefix with "_result:":

private void RunFunc(Func<object> func)
{
    if (JobContext.IsJob)
    {
        JobContext.Job.Status.AddMessage(
            JsonConvert.SerializeObject("_result:" + func.Invoke())
        );
    }
}

In this implementation I am using Newtonsoft for serialization, but this could potentially be any serialization mechanism.

Finally, we will need to change the Query method to take a type parameter, indicating the type of result we except (e.g. a JobResult):

public JobStatus<T> Query<T>(string jobName) where T : class
{
    var job = JobManager.GetJob(jobName);
    if (job == null)
    {
        Log.Warn($"JobService: Unknown job queried: {jobName}",
           this);
        return null;
    }

    return new JobStatus<T>(job);
}

Updating the JobStatus

We will also make a few changes to the JobStatus to be able to provide us with result of a specific type once the job is done. Below I have only added the changes, the rest of the code is unchanged:

namespace Services
{
    public class JobStatus<T> where T : class
    {
        string[] messages;
        const string resultPrefix = "_result:";

        public JobStatus(BaseJob job)
        {
            messages = job.Status.GetMessages();
        }

        public string[] Messages
        {
            get
            {
                // Filter out result message
                return messages.Where(
                    m => !m.StartsWith(resultPrefix)
                ).ToArray();
            }
            set
            {
                messages = value;
            }
        }

        public T Result
        {
            get
            {
                if (!IsDone)
                {
                    return null;
                }

                // Deserialize result message
                var result = messages.FirstOrDefault(
                    m =&gt; m.StartsWith(resultPrefix)
                );

                if (
                    string.IsNullOrEmpty(result) || 
                    result.Length &lt;= resultPrefix.Length
                )
                    return null;

                return 
                JsonConvert.DeserializeObject<T>
                (result.Substring(resultPrefix.Length));
            }
        }
    }
}

The changes above filter out the special “result” message when the messages are requested, avoiding mixing normal messages from the job with our serialized “result” message.

But when a job is done, the JobStatus now allow us to recieve the result, deserializing the special “result” message.

Calling the JobService and getting the result

With these changes in place, we are now able to start a Sitecore Job and – once the job is done – receive the result of our calculation:

var jobService = new JobService();

var jobName = new JobService().StartJob(
    "Calculation",
    DoCalculation
);

JobResult jobResult = null;

int i = 0;

// Potentially I could do some other work here 
// But for now I will simply wait 10 seconds
// For the job to complete
while (i < 10)
{
    Thread.Sleep(1000);
    var jobStatus = jobService.Query<JobResult>(jobName);
    if (!jobStatus.IsDone)
    {
        i++;
        continue;
    }
    jobResult = jobStatus.Result;
    break;
}

return $"The result of calculation {jobResult.CalculationId} is {jobResult.CalculationSum}";

The main drawback of this approach is that the type safety is moved to the caller of the Query method and the type of the result is not stored directly on the job. However, with this approach it is possible to return serializable objects from a Sitecore Job.

I hope you found this post informative. While the implementations presented are rather crude it might be a steppingstone for further exploration of the possibilities offered by Sitecore Jobs.