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 => m.StartsWith(resultPrefix)
);
if (
string.IsNullOrEmpty(result) ||
result.Length <= 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.