Monday, July 6, 2015

SharePoint Timer Jobs: Top 10 Tips

I have had to write several SharePoint 2013 timer jobs for a recent project and decided it would be a good thing to capture and share my top 10 tips for developing and testing SharePoint timer jobs.
Each of these is explained in further detail below. They are:
    
    1.   Develop with a Windows Console Application
Start by creating a console application with your timer job logic in it. This is because it is MUCH easier and quicker to develop, debug, and test your code directly in a console application than by deploying a timer job and attaching to the owstimer service.

    2.   Make your job configurable
Use web or web application property bags to parameterize your timer job. This gives you control and flexibility over your job, often avoiding a re-deployment to make routine changes to the timer job processing.

    3.   Name your timer jobs
Give your jobs a common naming structure, and preferably one that will show up on the first page of timer jobs in Central Administration. Giving them a common name groups your jobs together, and naming them such that they show up on the first page eliminates paging through the 100s of SharePoint OOB timer jobs to find your jobs.

    4.   Populate the job description
Override the Description property to insert your own timer job description text. Include a version number in the description text so that you’ll always know which code version is deployed and a general description of what the job does.

    5.   Update the progress bar
Calculate updates to the UpdateProgress property so that the progress bar displayed in Central Administration while your job is running accurately indicates the percent complete of your job processing.

    6.   Track job statistics
Provision counters and other variables to keep track of the work your timer job is doing and it’s elapsed runtime so that they can be logged or emailed upon job completion. This is helpful in evaluating unexpectedly long running or short running jobs.

    7.   Strategize errors and exceptions
Carefully consider errors/exceptions that should terminate the job immediately and mark the job unsuccessful verses errors/exceptions that should simply be logged and communicated.

    8.   Log errors/exceptions and success
Log errors and job success to the windows event log and SharePoint logs. Numbering and proving details in these logs will help you when your jobs unexpectedly fail and in seeing when the jobs run successfully.

    9.   Mark features with a custom image
    Use a custom image for your features so that you can easily spot your custom features verses SharePoint OOB features.

    10. Script deployment with PowerShell
Use a PowerShell script to deploy your timer job solution, restart the SPTimerV4 service, and perform an IISRESET so that your deployment to each environment is repeatable and consistent.

Details:

#1 – Develop with a Windows Console Application
This the biggest and most time saving tip of them all. The reason is the speed and ease at which you will be able to develop, debug, and test your code greatly outweighs the little bit of extra work it will take to convert your console application to a timer job once you have all of the logic debugged and tested. I even keep the console application in source code for future job troubleshooting or enhancements.

To create a SharePoint console application in Visual Studio (2013 Premium) you will need to do the following:
v  Create a new Visual C# (or VB) Windows Console Application, make sure you select the current .Net Framework (4.5 for SharePoint 2013).
v  Under project properties - Build, set your platform target to x64
v  Add a Reference to the “Microsoft SharePoint.dll” which is located in C:\Program Files\Common Files\microsoft shared\Web Server Extensions\15\ISAPI

Now you are ready to start adding your code. You need to keep in mind that you are emulating a timer job. The only thing the timer job will know is its parent WebApplication. In the actual timer job in the Execute method override of the SPjobDefinition class, that will simply be this.WebApplication

However, in our console job we need a line of code like this;
SPWebApplication webapp = SPWebApplication.Lookup(new Uri(http://sharepoint.contoso.com));

The current WebApplication is all you can assume you have to start with. You must build your code from there. I suggest using the Console Application Program.cs Main method as the equivalent of the timer job Execute method. Be sure to use classes, methods, and properties just like you intend to use in your timer job.

The advantages of approaching your development like this are:
ü  Run immediately from Visual Studio
ü  Set breakpoints as needed
ü  Write data to console (remove these when converting to timer job)
ü  Change variable values as needed via the debugger
ü  Terminate execution as needed
ü  No need to deploy, restart SPTimerV4, IISReset, attach to owstimer service each time you wish to test or debug your code

The disadvantages I’ve found are:
Ø  console applications require your methods and properties to be static whereas timer jobs do not so some conversion may be necessary
Ø  You should remove all Console commands before converting to an actual timer job
Ø  If you have existing classes in a timer job solution you wish to leverage, you have to recreate them in your console application

#2 – Make your job configurable
I have already written a blog post of this here, so I will not repeat it in its entirety. In terms of a timer job, I think this tip is especially useful because there are few other options for making the behavior of your timer job logic configurable and dynamic.

The tricky part of using property bags with a timer job is determining where the property bag will be. On one hand it makes sense to put the property bag at the web application level, however, I have found setting and maintaining the properties at the web application level to be more challenging than setting them at the web level. Also, if your timer job spans sites/webs, it might be useful to be able to dynamically modify behavior on a per site basis.

In the last implementation of this I did, I had a Content Hub site that I know will always exist at the same relative path and therefore I used the property bag on the RootWeb of the Content Hub site for storage and retrieval of my dynamic properties.

I have used properties to modify timer job behavior in the following ways:
·         Turn on/off email notifications
·         Set email TO: and CC: email addresses
·         Set query attributes, such as a cutoff date or number of hours
·         Set a list of sites to process
·         Set a SQL database server and database name since these often vary between development, testing, and production

Essentially, you can use properties to parameterize your timer jobs and increase their flexibility.

#3 – Name your timer jobs
The naming of your timer jobs can make finding you timer jobs in the list of 100s of SharePoint Timer Jobs easier or more difficult. Giving them a common prefix will group your jobs together, and making that prefix alphabetically early will help get your jobs on the first page of timer jobs displayed in SharePoint Central Administration, under Monitoring – Review Job Definitions.

Although I have redacted some information you can see in the screen capture below, that my 4 timer jobs are named with a common prefix, grouped together, and occur within the first 100 timer jobs displayed by default.

Central Administration: Job Defintions
How do you name your jobs? This must be done in the feature event receiver. See the C# code snippet below, look for the section commented as TIP #3.

C# (feature receiver code snippet)
public class RecordDestructionEventReceiver : SPFeatureReceiver
{

    // TIP #3: Create a constant with our job name to use throughout
    const string RecordDestruction_JobName = "ABC.Contoso.TimerJobs: RecordDestruction;

    #region Public Overrides

    /// <summary>
    /// When feature is activated, delete and re-add the timer job
    /// </summary>
    public override void FeatureActivated(SPFeatureReceiverProperties properties)
    {

        // Get a reference to the web application
        SPWebApplication webApp = properties.Feature.Parent as SPWebApplication;

        // Delete job if exists
        DeleteJob(webApp, RecordDestruction_JobName);

        // Re add the job
        CreateRecordDestructionJob(webApp);

    } // end FeatureActivated

    /// <summary>
    /// When feature is deactivated, delete the timer job
    /// </summary>
    public override void Deactivating(SPFeatureReceiverProperties properties)
    {

        // Get a reference to the web application
        SPWebApplication webApp = properties.Feature.Parent as SPWebApplication;

        // Delete job if exists
        DeleteJob(webApp, RecordDestruction_JobName);

    } // end FeatureDeacctivating

    #endregion Public Overrides

    #region Private Methods

    /// <summary>
    /// Create and schedule a the timer job
    /// </summary>
    /// <param name="webApp">
    /// SPWebApplication: The web application object to create the timer job on
    /// </param>
    private static void CreateRecordDestructionJob(SPWebApplication webApp)
    {

            // Create the job
            RecordDestruction job = new RecordDestruction(RecordDestruction_JobName, webApp);

            // Set the default schedule
            SPDailySchedule dailySchedule = new SPDailySchedule();
            dailySchedule.BeginHour = 4;
            dailySchedule.EndHour = 5;

            // Apply the schedule to the job
            job.Schedule = dailySchedule;
            job.Update();

    } // end CreateRecordDestructionJob

    /// <summary>
    /// Delete the timer job with the passed job name
    /// </summary>
    /// <param name="webApp">
    /// SPWebApplication: The web application object to delete the job from
    /// </param>
    /// <param name="jobName">
    /// string: The name of the job to delete
    /// </param>
    private static void DeleteJob(SPWebApplication webApp, string jobName)
    {

        // Loop through all job definitions
        foreach (SPJobDefinition job in webApp.JobDefinitions)
        {

            // When found, delete it and exit the loop
            if ((job.Name == jobName))
            {
                job.Delete();
                break;
            }

        } // end for each job

    } // end DeleteJob

    #endregion Private Methods


#4 – Populate the job description
When you click on a timer job in Central Administrator, it will be helpful to you and others to see a description of what the job does and if there are any restrictions related to the job. I have found it also helpful to display the job version number here so I can always be sure of exactly which version of my timer job is deployed. The following figure shows what my job looks like in Central Admin:

Central Administration: Job Definition
You set the description by overriding the Description property of the SPJobDefinition class. For example, see TIP #4 in the code snippet below:

C# (SPJobDefinition code snippet)
class RecordDestruction : SPJobDefinition
{

    // Create constants with our job name, description and version
    // to use throughout
    const string RecordDestruction_JobName = "ABC.Contoso.TimerJobs: RecordDestruction;
    const string RecordDestruction_JobDescription = "This timer job processes each record series, quieries for records eligible for destruction and manages the destruction approval process for Contoso records";
    const string RecordDestruction_JobVersion = "V000.07";

    #region Constructors

    public RecordDestruction()
        : base()
    {
    } // end RecordDestruction()

    public RecordDestruction(string jobName, SPService service, SPServer server, SPJobLockType lockType)
        : base(jobName, service, server, lockType)
    {
           
        this.Title = RecordDestruction_JobName;

    } // end RecordDestruction(jobname, service, server, lockType)

    public RecordDestruction(string jobName, SPWebApplication webApp)
        : base(jobName, webApp, null, SPJobLockType.Job)
    {

        this.Title = RecordDestruction_JobName;

    } // end RecordDestruction(jobname, webApp)

    #endregion Constructors

    #region Overrides

    /// <summary>
    /// TIP #4: Override the description to get our custom description text
    /// </summary>
    public override string Description
    {
        get
        {
            return string.Format("{0} - {1}", RecordDestruction_Version, RecordDestruction_Description);
        }
       
    } // end Description

    /// <summary>
    /// The execute method is where all of your timer job logic is placed
    /// </summary>
    public override void Execute(Guid targetInstanceId)
    {

        // Capture the job start time
        JobStartTime = DateTime.Now;

        // Do timer job logic here

    } // end Execute

    #endregion Overrides


#5 – Update the progress bar
When a timer job is running, SharePoint displays a progress bar.

Central Administration Running Jobs
You can have your timer job update this progress bar. Doing so may help you to determine if your job is stuck or still making progress. This is simple to do in the SPJobDefinition class but you’ll have to come up with your own formula for calculating the percent complete.  See the example below for how I calculate the percentage completion in this fictional example.

C# (SPJobDefinition code snippet)
class RecordDestruction : SPJobDefinition
{

    #region Constructors

    #region Overrides

    /// <summary>
    /// The execute method is where all of your timer job logic is placed
    /// </summary>
    public override void Execute(Guid targetInstanceId)
    {

        // Query for records to process
        SPListItemCollection lic = QueryRecords();

        // Create a current record counter
        int CurrentRecord = 0;

        // Process each queried record
        foreach (SPListItem li in lic)
        {

            // Increment the record counter
            CurrentRecord += 1;

            // Process the record
            ProcessRecord(li);

            // TIP #5: Update the progress bar
            this.UpdateProgress(CurrentRecord / lic.Count);
        
        }

    } // end Execute

    #endregion Overrides


#6 – Track job statistics
Since there is virtually no user interaction with a timer job, I like to track some statistics about my job and log them to the event and/or ULS log when my job completes. Each job will be unique in terms of what to log as it will depend on what your job is doing, but here are a few examples of the types of things I have logged:
·         Job start time
·         Job end time
·         Job elapsed time
·         Number of records queried
·         Number of records updated

Here is an example of a successful timer job completion event log:

Event Viewer: Job Completion Event
For code snippets regarding how to create log entries, see Tip #8.

#7 – Strategize errors and exceptions
With timer jobs you need to think about what errors and exceptions should terminate your job immediately versus which errors/exception can simply be logged and allow the timer job to continue.

For example, let’s say we have a timer job that processes some type of requests that are queued up in a list. If our job can’t find the list, or the web that hosts the list – that would be an example of an error that should terminate the timer job immediately with a failure. However, if our job is processing each request and hits a request that doesn’t make sense then that can simply be logged and the timer job can continue and process all the other requests in the list.

The following fictitious code snippet attempts to illustrate this point:

C# (SPJobDefinition code snippet)
class RecordDestruction : SPJobDefinition
{

    #region Constructors (omitted)

    #region Overrides

    /// <summary>
    /// The execute method is where all of your timer job logic is placed
    /// </summary>
    public override void Execute(Guid targetInstanceId)
    {

        try
        {

            // Instantiate a ProcessingLog to capture processing information
            StringBuilder ProcessingLog = new StringBuilder();

            using (SPSite site = new SPSite(weburl)
            {

                using (SPWeb web = site.OpenWeb())
                {

                     // Get the list of items to process
                    SPList list = web.Lists.TryGetList(listname);
                    if (list == null)
                    {

                        // If the list is not found, we cannot continue
                        throw new ArgumentNullException(listname, string.Format("List {0} not found on web {1}", listname, weburl));

                    }

                    // Process the items in the list
                    foreach (SPListItem item in list.Items)
                    {

                        // Call a method to perform some validation of the item
                        if (ValidateItemProperties(item)
                        {

                            // If validated, process the item

                        }
                        else
                        {

                            // TIP #7: Otherwise, handle non-fatal error
                            ProcessingLog.AppendFormat("\nItem {0} ({1}): Failed validation.", item.Title, item.Id);

                        }

                    } // end foreach item

                } // end using web

            } // end using site

            // Call a method to log the final results, passing the log
            LogJobCompletion(ProcessingLog);

        } // end try
        catch (Exception ex)
        {

            // Log the error here, see Tip #8
            LoggingService.LogMessage(
                16210,
                16210,
                LoggingService.LogCategory.Log_Exception.ToString(),
                EventSeverity.Error,
                TraceSeverity.High,
                String.Format("Fatal exception caught in YourJobName.Execute: {0}", ex.Message),
                ex.StackTrace
                );

            // Re-Throw the exception to force the job to fail
            throw ex;


        } // end catch

    } // end Execute

    #endregion Overrides


#8 – Log errors/exceptions and success
Log timer job errors/exceptions and success to the Event Log and/or the SharePoint ULS log. This is the same way SharePoint logs timer job errors and success. Create your own Source/Area name and your own numbering scheme so that you can filter the logs easily to see only events associated with your custom timer jobs.

You can do this by creating your own methods within a LoggingService based on the SPDiagnosticsServiceBase class (namespace Microsoft.SharePoint.Administration). A simplified example of what this class might look like is below, an example of how to call it is above in TIP #7:

C# (LoggingService class code snippet)
class LoggingService : SPDiagnosticsServiceBase
{

    /// <summary>
    /// Define categories
    /// </summary>
    public enum LogCategory
    {
        None = 0,
        Log_Info = 1,
        Log_Error = 2,
        Log_Exception = 3,
    }

    /// <summary>
    /// Define our area name
    /// </summary>
    private static string Area_Name = "your area name";
    private static LoggingService _current;

    /// <summary>
    /// Custom Logging Service
    /// </summary>
    private LoggingService() :
        base("<your prefix>.Logging Service", SPFarm.Local) { }

    /// <summary>
    /// Logging service
    /// </summary>
    public static LoggingService Current
    {
        get
        {
            if (_current == null)
                _current = new LoggingService();
            return _current;
        }
    } // end Current

    /// <summary>
    /// Areas and category mapping
    /// </summary>
    protected override IEnumerable<SPDiagnosticsArea> ProvideAreas()
    {
        List<SPDiagnosticsArea> areas = new List<SPDiagnosticsArea>{
            new SPDiagnosticsArea(Area_Name, new List<SPDiagnosticsCategory>{
                new SPDiagnosticsCategory(LogCategory.Log_Info.ToString(), TraceSeverity.Verbose, EventSeverity.Information),
                new SPDiagnosticsCategory(LogCategory.Log_Error.ToString(), TraceSeverity.Unexpected, EventSeverity.Warning),
                new SPDiagnosticsCategory(LogCategory.Log_Exception.ToString(), TraceSeverity.High, EventSeverity.Error),
                })
        };
        return areas;
    } // end ProvideAreas

    /// <summary>
    /// Log message to the ULS log
    /// </summary>
    public static void LogMessageToULS(UInt32 traceID, string categoryName, TraceSeverity traceSeverity, string message, params object[] data)
    {
        SPDiagnosticsCategory category = LoggingService.Current.Areas[Area_Name].Categories[categoryName];
        LoggingService.Current.WriteTrace(traceID, category, traceSeverity, message, data);
    } // end LogMessageToULS

    /// <summary>
    /// Log message to the event log
    /// </summary>
    public static void LogMessageToEventLog(UInt16 eventID, string categoryName, EventSeverity eventSeverity, string message, params object[] data)
    {
        SPDiagnosticsCategory category = LoggingService.Current.Areas[Area_Name].Categories[categoryName];
        LoggingService.Current.WriteEvent(eventID, category, eventSeverity, message, data);
    } // end LogMessageToEventLog

    /// <summary>
    /// Log message to the ULS and event log
    /// </summary>
    public static void LogMessage(UInt16 eventID, UInt32 traceID, string categoryName, EventSeverity eventSeverity, TraceSeverity traceSeverity, string message, params object[] data)
    {
        SPDiagnosticsCategory category = LoggingService.Current.Areas[Area_Name].Categories[categoryName];

        LoggingService.Current.WriteEvent(eventID, category, eventSeverity, message, data);
        LoggingService.Current.WriteTrace(traceID, category, traceSeverity, message, data);
    } // end LogMessage

} // end class

#9 – Mark features with a custom image
This tip applies to any custom features you develop, not just timer job features. Providing a custom image, naming your features with a common prefix, and providing a good description will group your custom features together, make then easy to differentiate against SharePoint features, and help viewers understand what the feature does. For example, see the Web Application Features image below where my timer jobs are grouped together and use a custom image:

Central Administration: Web Application Features
What size should my image be? For feature logos your image size should be 31 pixels wide by 21 pixels tall.

How do I associate my image to my feature? You can configure them in Visual Studio, or code them directly into your feature manifest. Note this image capture also points out the feature description, and alternate text for the image should it not be available.

Visual Studio: Feature Properties
Finally, where do I put my images so that they can be referenced? There are probably multiple opinions regarding the answer to this question each with their own merits and issues. Since I generally install a MasterPage solution, I like to include my custom images (as well as custom .css and .js files) with my master page. This will put my images in a subfolder in the /layouts/images folder of the hive where they are accessible to all sites on the farm. This may not be the best solution in all circumstances and I encourage you to think about your farms configuration and needs before choosing a location for your images.

#10 – Script deployments with PowerShell
In most cases your will develop your timer jobs in a development environment, test them in one or more integrated test environments, and deploy them to a production environment. Scripting your deployment with PowerShell helps you insure consistent deployment across multiple environments.

What are the steps needed in a script to deploy, install, and activate a timer job solution?
1.   Deactivate features
2.   Uninstall solution
3.   Remove solution
4.   Add solution
5.   Install solution
6.   Activate features
7.   Restart OWS Timer Service
8.   Reset IIS

So you would think maybe 10-20 lines of PowerShell could handle this, but my deployment script is about 130 lines.

PowerShell (deploy a timer job)

    param(
        [Parameter(Mandatory=$true)][System.String]$webappurl,
        [Parameter(Mandatory=$true)][System.String]$wsppath,   
        [Parameter(Mandatory=$true)][System.String]$wsp,           
        [Parameter(Mandatory=$true)][System.String[]]$featureids)

try {

    # Verify the wsp
    $wspfullpath = ("{0}\{1}" -f $wsppath, $wsp);
    if (!(test-path $wspfullpath)) {
        throw ("'{0}' does not exist!" -f $wspfullpath);
    }
   
    # 1 - Deactivate FeatureIds
    write-host;
    write-host -f green "   1. Deactivating features...";
    foreach ($featureid in $featureids) {
        write-host -f green "      Feature ID: " -nonewline;
        write-host -f white $featureid;
        $Feature = Get-SPFeature -WebApplication $webappurl | where { $_.Id -eq $featureid }
        if ($Feature -ne $null)
        {
            Disable-SPFeature -Identity $featureid -url $webappurl -force -confirm:$false;
            $i = 0;
            While ($Feature -ne $null)
            {
                write-host -f yellow "         Waiting for feature to deactivate...";
                Start-Sleep -s 10;
                $Feature = Get-SPFeature -WebApplication $webappurl | where { $_.Id -eq $featureid }
            }
        }
        else
        {
            write-host -f yellow "         Feature is not currently active...";
        }
    }
   
    # 2 - Uninstall the solution
    write-host -f green "   2. Uninstalling solution: " -nonewline;
    write-host -f white $wsp;
    $Sols = Get-SPSolution | where { $_.Name -eq $wsp }
    if (($Sols -ne $null) -and ($Sols.Deployed -eq "True"))
    {
        Uninstall-SPSolution -Identity $wsp -confirm:$false;
    }
    else
    {
        write-host -f yellow "         Solution uninstall not required.";
    }
   
    # If solution exists, wait until uninstalled before removing
    $Sols = Get-SPSolution | where { $_.Name -eq $wsp }
    While (($Sols -ne $null) -and ($Sols.Deployed -eq "True"))
    {
        write-host -f yellow "         Waiting for solution to uninstall...";
        Start-Sleep -s 10;
        $Sols = Get-SPSolution | where { $_.Name -eq $wsp }
    }

    # 3 - Remove the solution
    write-host -f green "   3. Removing solution...";
    if ($Sols -ne $null)
    {
        Remove-SPSolution -Identity $wsp -confirm:$false;
    }
    else
    {
        write-host -f yellow "         Solution removal not required.";
    }

    # 4 - Add the solution   
    write-host -f green "   4. Adding solution...";
    Add-SPSolution $wspfullpath | out-null;
   
    $Sols = Get-SPSolution | where { $_.Name -eq $wsp }
    While ($Sols -eq $null)
    {
        write-host -f yellow "         Waiting for solution to be added...";
        Start-Sleep -s 10;
        $Sols = Get-SPSolution | where { $_.Name -eq $wsp }
    }

    # 5 - Install the solution   
    write-host -f green "   5. Installing solution...";
    Install-SPSolution -Identity $wsp -GACDeployment -force;
   
    While ($Sols.Deployed -ne "True")
    {
        write-host -f yellow "         Waiting for solution to be installed...";
        Start-Sleep -s 10;
        $Sols = Get-SPSolution | where { $_.Name -eq $wsp }
    }
   
    # 6 - Activate features
    write-host -f green "   6. Activating features...";
    foreach ($featureid in $featureids) {
        write-host -f green "      Feature ID: " -nonewline;
        write-host -f white $featureid;
        $Feature = Get-SPFeature -WebApplication $webappurl | where { $_.Id -eq $featureid }
        if ($Feature -eq $null) {
            Enable-SPFeature -Identity $featureid -url $webappurl;
        }
        While ($Feature -eq $null)
        {
            write-host -f yellow "         Waiting for feature to activate...";
            Start-Sleep -s 10;
            $Feature = Get-SPFeature -WebApplication $webappurl | where { $_.Id -eq $featureid }
        }
    }
   
    # 7 - Restart the timer service
    write-host -f green "   7. Restarting OWS Timer Service...";
    restart-service sptimerv4;
   
    # 8 - IISRESET
    write-host -f green "   8. Performing iisreset...";
    iisreset;
       
    # Return success
    exit 0;


} # end try
catch {

    # Display information   
    write-host;
    write-host -f red -BackgroundColor yellow ("Exception caught in {0}: " -f $MyInvocation.MyCommand.Name) -nonewline;
    write-host -f red $_.Exception.Message;

    # Return failure
    exit 99;

} # end catch


No comments:

Post a Comment