Here is the link to the "home page" for this blog series.

Now when we got our CI/CD working, it's time to look at scheduling and exception handling. The order of the blog posts in this blog series it not arbitrary. I think it's very important to get everything ready before starting to implement any business rules. And by everything, I mean CI/CD and exception handling in general. For worker services, in particular, it's also setting up scheduling. Because sometimes you don't want your service to run right after you have deployed it. If you are on prem you have to share resources and if your worker service has to perform some heavy operations, it will affect other components. So, let's take a look how you can implement scheduling.

We are starting with the repo we created in the previous blog post called CreateAndInstallWorkerService.

The end result will be in the repo called SchedulingWorkerService.

The initial structure looks like this

Structure

Open appsettings.json and add configuration values when you want your worker service to run. F.ex. something like this

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "WhenToRun": {
    "Hours": "18",
    "Minutes": "00",
    "Seconds":  "00"
  }
}

I want my service to run 6 PM whatever time zone. I am not going into discussion about this. It's hard and a little bit beyond of our scope. If you want to learn how difficult it is to implement time, read blog posts by Jon Skeet: STORING UTC IS NOT A SILVER BULLET and Noda Time.

Here, time is local always.

Now we have to extract these values from appsettings and I am going to use Options pattern. To use it create a class called WhenToRunOptions.cs . Then, define the constant variable WhenToRun and this one should have the same value as in the appsettings.json and properties Hours, Minutes and Seconds. Something like this

namespace DemoWorkerService
{
    public class WhenToRunOptions
    {
        public const string WhenToRun = "WhenToRun";

        public int Hours { get; set; }
        public int Minutes { get; set; }
        public int Seconds { get; set; }
    }
}

Now open your program.cs and add following code

public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .UseWindowsService()
                .ConfigureServices((hostContext, services) =>
                {
                    services
                        .Configure<WhenToRunOptions>(
                            hostContext
                                .Configuration
                                    .GetSection(WhenToRunOptions.WhenToRun));

                    services.AddHostedService<Worker>();
                });

Here we are kind of telling framework to map WhenToRun section from json file to WhenToRunOptions class.

Open the Worker.cs If you remember this class has base class called BackgroundService and in this class we have 3 methods, we can override.

Create all these 3 methods in the Worker.cs.

And add WhenToRunOptions to the constructor, like this  

Add following code to the StartAsync method

public override async Task StartAsync(CancellationToken cancellationToken)
{
    try
    {
        _logger.LogWarning($"Aplication DemoWorkerService started.");
        await StartAt(cancellationToken);
        await base.StartAsync(cancellationToken);
    }
    catch (TaskCanceledException)
    {
        //ignore StopAsync will be called
    }
    catch (Exception exception)
    {
        _logger.LogError($"Got exception on start up." +
            $"Exception is {exception.Message}. Stopping service.");
        await StopAsync(cancellationToken);
    }            
}

A couple of interesting points here.

First, we are starting to implement exception handling. Exception handling is extremely easy to implement when you know the basics. There are two main rules when it comes to exceptions handling, actually three

  1. Try/catch exceptions on the topmost level (like main method or other entry points level)
  2. Try/catch exceptions on the lowest level where you expect something can go wrong
  3. Don't pollute your code with try/catch everywhere

That's it. Only three main rules.

Second is always log when application started and when application stopped. Trust me this is very important information to have when you are going to investigate your production errors. And, yes, you are going to do it or probably other people. Unfortunately, all and I mean all applications we create has some errors from time to time. This is not me; this is statistics: each 5th line of code  introduces new bug. With just this code we introduced probably two bugs. It's kind of cool, isn't it? :-)

Our job as developers is to make it as easy as possible to find bugs in the production code and of course write as many tests as necassery to be sure that we detect as many bugs as possible as yearly as possible. By writing tests, we minimize the number of errors and increase the quality of the code.

Third is handling of TaskCanceledException. When we stop the service, this exception is triggered. That's why in this case we do nothing, the framework will call StopAsync method.

And forth if we get any exceptions, we log those and stop the service. At this point you find these exceptions in the Event Viewer only. But a little bit later I will show you where you should log all exceptions in addition to the Event Viewer. Keep reading.

Now create StartAt method. And add following code

private async Task StartAt(CancellationToken cancellationToken)
{
    var timeToRun = new DateTimeOffset(
        DateTimeOffset.Now.Year,
        DateTimeOffset.Now.Month,
        DateTimeOffset.Now.Day,
        _options.Hours,
        _options.Minutes,
        _options.Seconds,
        0,
        DateTimeOffset.Now.Offset);

    int timeToSleep = GetTimeToSleep(timeToRun);

    _logger.LogWarning($"Aplication DemoWorkerService started" +
        $" and will sleep for {timeToSleep} milliseconds.");

    await Task.Delay(timeToSleep, cancellationToken);
} 

Here, we are creating DateTimeOffset object based on appsettings settings we defined. Check how we are using options here. Then we call GetTimeToSleep function to get how many milliseconds we should sleep, log it and fall asleep.

GetTimeToSleep looks like this

private static int GetTimeToSleep(DateTimeOffset timeToRun)
{
    var now = DateTimeOffset.Now;
    var timespan = timeToRun - now;
    int timeToSleep;

    if (timespan < TimeSpan.Zero)
    {
        var time = TimeSpan.FromHours(24) + timespan;
        timeToSleep = Convert.ToInt32(time.TotalMilliseconds);
    }
    else
    {
        timeToSleep = Convert.ToInt32(timespan.TotalMilliseconds);
    }

    return timeToSleep;
}

If you don't like the math just skip and read next section, but if you like a little bit of math keep reading.

Pretend like we want our service to run 15 of October 2020 15:00:00, again whatever time zone, your local time. This is our time to run. We find first what is time right now, when we run the application. Let's say we deployed our application 15 o'clock.  So now is f.ex. 15 of October 2020 14:00:00. So, we find the timespan, it is - 01:00:00. timespan is actually less than 0 or TimeSpan.Zero. Then time will be, first we find out how many hours in the  day (24) and plus the timespan. In this case it will be

24:00:00 - 01:00:00 = 23:00:00

Then we find out how many milliseconds 23 hours is and return the value in milliseconds.

That's it. We defined our scheduling and started to implement production ready exception handling.

Here is the link to the "home page" for this blog series.

If you like my post image, you can free download it from here.

Let me know in the comments if you experienced any troubles or if you have any feedbacks. Don't forget to subscribe to get news from Sergey .NET directly to your mailbox.


Links in this blog post

CreateAndInstallWorkerService Repo

SchedulingWorkerService Repo