tirsdag den 19. november 2013

Creating timebased tasks in Sitecore

From time to time, our customers would like to run taks at a certain time each day.

To be able to do this, we have created a new concept, called Timebased Tasks, which works like normal tasks, but instead of a frequency has a timestamp defining when the task should run.

To do this, you need 6 things, some "dummy" items to define the days, a template for the tasks, a folder for the tasks, two classes and an include file adding the agent.

Dummy items:

Lets start with the dummy items, create this folder in the master database: "/sitecore/system/Modules/Timebased Tasks".

Then create 7 items below it (based on the "Standard Template" template, since we don't need any special fields on them), and call them: "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" and "Sunday".

Template:

We need a template to define a Timebased Task - create a new template called "Timebased Task" somewhere where it makes sense to you, and add the following fields to it (all should be marked shared):

Name: Type: Source:
Command Droplink /sitecore/system/tasks/commands
Items Treelist /sitecore
Start Date Date  
End Date Date  
Frequency Checklist /sitecore/system/Modules/Timebased Tasks
Time Of Day Single-Line Text  
Asynchronous Checkbox  
Auto Remove Checkbox  
Last Run Datetime  

Folder:

Now we have to create the folder where the tasks is to be placed, so create this folder: "/sitecore/system/Tasks/Timebased Schedules"

Once the folder has been created, replace the insert options on the folder, so you can create items based on the template you just created.

The code:

Finally we get to the fun part - the code :-)

First we need a class called TimebasedScheduleItem - this class represents the schedule item, and extends the normal schedule item (to be allowed to be passed to a task, as if it was a normal ScheduleItem object):

using System;
using System.Globalization;
using System.Linq;

using Sitecore;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Jobs;
using Sitecore.SecurityModel;
using Sitecore.Tasks;

public class TimebasedScheduleItem : ScheduleItem
{
    public TimebasedScheduleItem(Item innerItem) : base(innerItem)
    {
    }

    public new bool Asynchronous
    {
        get
        {
            return InnerItem["Asynchronous"] == "1";
        }
    }

    public new bool AutoRemove
    {
        get
        {
            return InnerItem["Auto Remove"] == "1";
        }
    }

    public new CommandItem CommandItem
    {
        get
        {
            ReferenceField field = InnerItem.Fields["Command"];

            if (field == null || field.TargetItem == null)
            {
                return null;
            }

            return field.TargetItem;
        }
    }

    public new bool Expired
    {
        get
        {
            // First we check if the end date is before today (meaning that we are expired).
            if (EndDate < DateTime.Today)
            {
                // It is, so we return true.
                return true;
            }

            // If the end date is today.
            if (EndDate == DateTime.Today)
            {
                TimeSpan timeOfDay;

                // We try and parse the time of day.
                if (!TryParseTimeOfDay(out timeOfDay))
                {
                    // We failed, so we considder the task expired.
                    return true;
                }

                // Then we take the end date, and adds the time of day to it.
                // This is because the last possible time it can run would be the time of day on the end date.
                DateTime totalEndDate = EndDate.Add(timeOfDay);

                // Finally we check if the total end date is before now, and that the task has been run today.
                // This is to make sure that task gets run the last day if needed.
                return totalEndDate < DateTime.Now && LastRun.Date == DateTime.Today;
            }

            // If we gets here, end date is in the future, so we are not expired yet.
            return false;
        }
    }

    public new bool IsCompleted
    {
        get
        {
            // A task is completed (never going to run again) when the NextRun is the minimum possible DateTime value.
            return NextRun == DateTime.MinValue;
        }
    }

    public new bool IsDue
    {
        get
        {
            // If the task has been run today already, we return false.
            if (LastRun.Date == DateTime.Today)
            {
                return false;
            }

            // If the start date is in the future, or the task is expired, we return false.
            if (StartDate > DateTime.Today || Expired)
            {
                return false;
            }

            // If the day of the week it is today is not checked in the checklist, we return false;
            if (!Frequency.Contains(DateTime.Now.DayOfWeek.ToString()))
            {
                return false;
            }

            TimeSpan timeOfDay;

            // Then finally we try and parse the time of day field, and figures out if it is before the current time.
            // This makes sure that task does not get run too early - since if we get here, the task is supposed to run today.
            return TryParseTimeOfDay(out timeOfDay) && timeOfDay <= DateTime.Now.TimeOfDay;
        }
    }

    public new Item[] Items
    {
        get
        {
            MultilistField field = InnerItem.Fields["Items"];

            return (from id in field.TargetIDs
                    select InnerItem.Database.GetItem(id)).ToArray();
        }
    }

    public new DateTime LastRun
    {
        get
        {
            DateField field = InnerItem.Fields["Last Run"];

            return field.Value.Length == 0 ? DateTime.MinValue : field.DateTime;
        }

        set
        {
            using (new SecurityDisabler())
            {
                InnerItem.Editing.BeginEdit();
                InnerItem["Last Run"] = DateUtil.ToIsoDate(value);
                InnerItem.Editing.EndEdit();
            }
        }
    }

    public new DateTime NextRun
    {
        get
        {
            TimeSpan timeOfDay;

            // First we test if the task is due, in which case the next run is now, so we return DateTime.Now .
            if (IsDue)
            {
                return DateTime.Now;
            }

            // If the task is expired, no days is selected in the checklist or we are unable to parse the time of day field.
            // We return DateTime.MinValue, which indicates that there is no next run.
            if (Expired || Frequency.Length == 0 || !TryParseTimeOfDay(out timeOfDay))
            {
                return DateTime.MinValue;
            }

            // If the checklist contains the day of the week that today is, and the time of day is before now, we take today,
            // and add the time of day to it, and return it.
            if (Frequency.Contains(DateTime.Now.DayOfWeek.ToString()) && timeOfDay >= DateTime.Now.TimeOfDay)
            {
                return DateTime.Today.Add(timeOfDay);
            }

            DateTime tmpDate = DateTime.Today.Add(timeOfDay);

            // If we get here, the task is not expired, there is at least one day selected in the checklist, so we try adding a day to the
            // temporary date variable above, untill the day of the week that it represents is checked in the checklist - thats the next run :-)
            // The reason for 7 is because there is 7 days in a week, so we should in theory never hit the 7th run, since that would be back to
            // the weekday of today.
            for (int i = 0; i < 7; i++)
            {
                tmpDate = tmpDate.AddDays(1);

                if (Frequency.Contains(tmpDate.DayOfWeek.ToString()) && tmpDate.Date <= EndDate)
                {
                    return tmpDate;
                }
            }

            // If we get here, the task is not able to be run, so we just return DateTime.MinValue to make sure it never gets run.
            return DateTime.MinValue;
        }
    }

    public new ScheduleField Schedule
    {
        get
        {
            // We don't use the ScheduleField here, since we have our own field for this, so we overwrite it and return null instead.
            // This also prevents the normal DatabaseAgent from working with the TimebasedScheduleItem objects (like it is ever to get its hands on one).
            return null;
        }
    }

    private DateTime StartDate
    {
        get
        {
            DateField field = InnerItem.Fields["Start Date"];

            if (field == null)
            {
                return DateTime.MinValue;
            }

            return field.Value.Length == 0 ? DateTime.MinValue : field.DateTime.Date;
        }
    }

    private DateTime EndDate
    {
        get
        {
            DateField field = InnerItem.Fields["End Date"];

            if (field == null)
            {
                return DateTime.MinValue;
            }

            return field.Value.Length == 0 ? DateTime.MinValue : field.DateTime.Date;
        }
    }

    private string[] Frequency
    {
        get
        {
            MultilistField field = InnerItem.Fields["Frequency"];

            if (field == null)
            {
                return new string[0];
            }

            // We take all the selected weekdays, and return the item names of those that are selected.
            return (from id in field.TargetIDs
                    select InnerItem.Database.GetItem(id).Name).ToArray();
        }
    }

    public new void Execute()
    {
        try
        {
            // If the commanditem is null, we cannot run the task.
            if (CommandItem == null)
            {
                return;
            }

            // We execute the task (async or sync depending on what is checked.)
            if (Asynchronous)
            {
                ExecuteAsynchronously(CommandItem);
            }
            else
            {
                ExecuteSynchronously(CommandItem);
            }

            // Then we set the LastRun to now.
            LastRun = DateTime.Now;

            // And if the task is complete (not to ever run again), and is set to be auto removed, we remove it.
            if (IsCompleted && AutoRemove)
            {
                Log.Info("Schedule is completed. Auto removing schedule item: " + Name, this);
                InnerItem.Delete();
            }
        }
        catch (Exception e)
        {
            Log.Error("Error executing schedule item: " + Name, e, this);
            throw;
        }
    }

    public new string GetJobName(CommandItem command)
    {
        if (command == null)
        {
            throw new ArgumentNullException("command", "command must not be null.");
        }

        // We return the combined name of the task, which is unique to the command
        return "TimebasedScheduleCommand '" + command.InnerItem.ID + "'";
    }

    private void ExecuteAsynchronously(CommandItem command)
    {
        // Here we run the task asynchronously, so we create a Job, and start it.
        string jobName = GetJobName(command);
        JobManager.Start(new JobOptions(
            jobName,
            "timebasedschedule",
            "scheduler",
            command,
            "Execute",
            new object[] { Items, this }) { AtomicExecution = true });
    }

    private void ExecuteSynchronously(CommandItem command)
    {
        // Here we run the task synchronously, so we just call execute on it.
        command.Execute(Items, this);
    }

    private bool TryParseTimeOfDay(out TimeSpan timeOfDay)
    {
        DateTime result;

        // We try and parse the time of day field - it is supposed to be in the format "HH:mm".
        if (!DateTime.TryParseExact(InnerItem["Time Of Day"], "HH:mm", null, DateTimeStyles.None, out result))
        {
            // We failed, so we set timeOfDay to an empty TimeSpan and return false.
            timeOfDay = new TimeSpan();
            return false;
        }

        // We succeded, so we set timeOfDay to the parsed value, and return true.
        timeOfDay = result.TimeOfDay;
        return true;
    }
}

Then we need another class, which is our database agent, which has the job of finding the tasks, and starting them when needed - the class is called TimebasedDatabaseAgent, and the code looks like this:

using System;
using System.Linq;

using Sitecore;
using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Jobs;

public class TimebasedDatabaseAgent
{
    public TimebasedDatabaseAgent(string databaseName, string scheduleRoot)
    {
        Error.AssertString(databaseName, "databaseName", false);
        Error.AssertString(scheduleRoot, "scheduleRoot", false);

        Database = Factory.GetDatabaseNames().Contains(databaseName) ? Factory.GetDatabase(databaseName) : null;
        ScheduleRoot = scheduleRoot;
        LogActivity = true;

        if (Database == null)
        {
            LogInfo("TimebasedDatabaseAgent skipping, database is null");
        }
    }

    public Database Database { get; private set; }

    public bool LogActivity { get; set; }

    public string ScheduleRoot { get; private set; }

    public void Run()
    {
        // If we have no database (like running on a content delivery server without the master database), we return.
        if (Database == null)
        {
            return;
        }

        LogInfo("TimebasedDatabaseAgent started. Database: " + Database.Name);

        // First we find the current job, and the schedules.
        Job job = Context.Job;
        TimebasedScheduleItem[] schedules = GetSchedules();

        LogInfo("Examining schedules (count: " + schedules.Length + ")");

        // Then we test if the job is valid, and if it is, we set the status on it.
        if (IsValidJob(job))
        {
            job.Status.Total = schedules.Length;
        }

        // For each found task
        foreach (TimebasedScheduleItem scheduleItem in schedules)
        {
            try
            {
                // If the schedule is due, we execute it.
                if (scheduleItem.IsDue)
                {
                    LogInfo("Starting: " + scheduleItem.Name + (scheduleItem.Asynchronous ? " (asynchronously)" : string.Empty));
                    scheduleItem.Execute();
                    LogInfo("Ended: " + scheduleItem.Name);
                }
                else
                {
                    LogInfo("Not due: " + scheduleItem.Name);
                }

                // If the schedule is expired and set to be auto removed, we remove it.
                if (scheduleItem.AutoRemove && scheduleItem.Expired)
                {
                    LogInfo("Schedule is expired. Auto removing schedule item: " + scheduleItem.Name);
                    scheduleItem.Remove();
                }
            }
            catch (Exception e)
            {
                LogInfo("Failed to run tasks, error: " + e.Message);
            }

            // If the job is valid, we increase the processed counter.
            if (IsValidJob(job))
            {
                job.Status.Processed += 1;
            }
        }
    }

    private static bool IsValidJob(Job job)
    {
        // We check if the job is in the right category
        return job != null && job.Category == "schedule";
    }

    private TimebasedScheduleItem[] GetSchedules()
    {
        // First we find the root folder.
        Item item = Database.Items[ScheduleRoot];

        // If we found it.
        if (item != null)
        {
            // Then we take all descendants from it, which is based on the template "Timebased Schedule", and return them as an array.
            return (from foundItem in item.Axes.GetDescendants()
                    where foundItem.TemplateName == "Timebased Schedule"
                    select new TimebasedScheduleItem(foundItem)).ToArray();
        }

        // We did not find the root folder, so we return an empty array.
        return new TimebasedScheduleItem[0];
    }

    private void LogInfo(string message)
    {
        // If logging is enabled, we log the message.
        if (LogActivity)
        {
            Log.Info(message, this);
        }
    }
}

Thats all the code needed, now we just need to create an include file, to configure Sitecore to use our new task system.

Include file:

The include file looks like this, and assumes you have placed the code in an assembly called TimebasedTasks:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:x="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <scheduling>
      <frequency>00:00:30</frequency>
      <!-- Agent to process timebased schedules embedded as items in a database -->
      <agent type="TimebasedTasks.TimebasedDatabaseAgent, TimebasedTasks" method="Run" interval="00:00:30" patch:after="frequency">
        <param desc="database">master</param>
        <param desc="schedule root">/sitecore/system/Tasks/Timebased Schedules</param>
        <LogActivity>true</LogActivity>
      </agent>
    </scheduling>
  </sitecore>
</configuration>

Note that we have to change the frequency to 30 seconds to ensure, that our tasks gets executed the right minute (The value 30 is found by experimentation, anything higher have been causing tasks not to get run when they should - your milage may vary).

Summary:

That really all there is to it - now you are able to create tasks in the folder you created, and select the command to run, which days to run, and the time to run, in the format "HH:mm".

Simple, right?

Ingen kommentarer:

Send en kommentar