jøhnny tisdale

blog

write your own task scheduler in powershell
January 18, 2020

Windows comes with a task scheduler, but I have found it to be unreliable. For example, when the Windows task scheduler runs a PowerShell script, it does not always produce the same results as when I run the script myself. I'm sure this can be overcome if you have the patience to fiddle with the settings for long enough, but in my case it was preferable to simply write my own task scheduler. It doesn't take long to do, it gives you more fine grained control, and, quite frankly, it's fun.

Define your tasks.

Let's store our tasks as an array of objects.

$tasks = @(
    [PSCustomObject]@{
        name   = 'Failed Request Tracing';
        path   = 'C:\powershell\failed request tracing.ps1';
        remote = 'MyServer';
        time   = 'hourly'
    };
    [PSCustomObject]@{
        name   = 'Website Uptime';
        path   = 'C:\powershell\uptime.ps1';
        remote = $false;
        time   = 'hourly'
    };
    [PSCustomObject]@{
        name   = 'Printer Consumables';
        path   = 'C:\powershell\printers.ps1';
        remote = $false;
        time   = @(7, 13)
    };
);

Each object has the following properties:

  • name
    • An arbitrary, human-readable identifier. Call it whatever you want.
  • path
    • The absolute file path to the script, relative to the machine from which the script is to be run (see remote below).
  • remote
    • If the script must be run on another machine on your network, specify the name of that machine here. Otherwise, set it to $false. Note that if you do specify a machine name here, then the path you specify in path should be local to that machine.
  • time
    • The time(s) at which the task should be run. If you want this task to run every hour, set this to "hourly". If you only want this task to run at specific hours, specify those hours here as an array of integers. Use 24-hour time, so for 1 PM, write 13.

Create a while loop.

Now that we've defined our tasks, the rest of our code will go in an infinite while loop.

while (1) {
    #the rest of our code will go here
}

Remember that the code in the loop will be executed as long as the expression in parantheses evaluates to $true. We're putting 1 in the parentheses as this is the most concise expression to meet that requirement.

Create a DateTime object for the next hour.

We need to check whether any of our tasks should be run. We should perform this check every hour, on the hour. To that end, let's create a DateTime object representing the upcoming hour. So if we start our scheduler at 10:30 AM, we want 11:00 AM. In the subsequent loop, we want 12:00 PM, and so on.

We can produce this DateTime object with a single line of code:

$next = (Get-Date "$(($now = Get-Date).Year)/$($now.Month)/$($now.Day) $($now.Hour)`:00:00").AddHours(1)

However, that's not very readable. Let's break it down so we can understand what's actually happening here.

#get the current time
$now = Get-Date

#create a string for this time rounded down to the nearest hour
$string = "$($now.Year)/$($now.Month)/$($now.Day) $($now.Hour)`:00:00"

#create a datetime object from the string
$hour = Get-Date $string

#add one hour to the datetime object
$next = $hour.AddHours(1)

You may be wondering why I didn't just add 1 to the hour in the string:

$($now.Hour + 1)

The reason is that, if $now.Hour is equal to 23, an invalid result will be produced. It's possible to work around this with some if statements, but I prefer to let the DateTime object do the work for me.

Wait until the next hour

Now that we have a DateTime object representing the upcoming hour, we can wait until that time arrives.

while ((Get-Date) -lt $next) {
    Start-Sleep -s 1
}

This code checks to see if the current time is less than $next. If it is, it waits one second before checking again. Otherwise, the script continues.

Write the current time

Now that we've reached the next hour, let's write that hour to the console.

write-host "$($now.ToString('yyyy/MM/dd hh:mm:ss tt'))" -ForegroundColor Cyan

I do this so I can have a record showing that my scheduler actually did what it was supposed to do, when it was supposed to do it.

Run the tasks!

#loop through the tasks and run those that need to be run at this hour
foreach ($task in $tasks) {

    #skip this task if it doesn't need to be run at this hour
    if ($task.time -ne "hourly" -and $task.time -notcontains $now.Hour) { continue }

    #escape spaces in the path
    $path = $task.path.Replace(" ", "`` ")

    #write the name of the task to the console
    write-host "`t$($task.name)"

    #use a try block so an uncaught exception in the task doesn't kill the scheduler
    try {

        #run the task on the local machine
        if ($task.remote -eq $false) { Invoke-Expression $path }
    
        #run the task on a remote machine
        else {

            #start a session on the remote machine
            $session = New-PSSession -ComputerName $task.remote

            #define the script block that runs the task
            $block = {
                param($path)
                Invoke-Expression $path
            }

            #run the task!
            Invoke-Command -Session $session -ScriptBlock $block -ArgumentList $path
        }
    }
    
    #if the task failed, write error message to console
    catch {
        write-host "`t`tFailed to run." -ForegroundColor Red
        write-host '`t`t$($_.ErrorDetails.Message)' -ForegroundColor Red
    }

    #end the session on the remote machine
    if ($task.remote -ne $false) {
        Remove-PSSession -Session $session
    }
}

There you have it!

The code in full

Here is the full text of the scheduler script:

$tasks = @(
    <#
    [PSCustomObject]@{
        name   = "";        #name your task
        path   = "";        #the path to the script
        remote = $false;    #if the task should be run on a remote machine, enter machine name here
        time   = "hourly"   #if the task should only be run at specific times, enter array of integers
    };
    #>
)

while (1) {

    #set the target time (round up to the nearest hour)
    $next = (Get-Date "$(($now = Get-Date).Year)/$($now.Month)/$($now.Day) $($now.Hour)`:00:00").AddHours(1)

    #wait until the next hour
    while ((Get-Date) -lt $next) { Start-Sleep -s 1 }

    #write the current time
    write-host "$($now.ToString('yyyy/MM/dd hh:mm:ss tt'))" -ForegroundColor Cyan

    #loop through the tasks and run those that need to be run at this hour
    foreach ($task in $tasks) {

        #skip this task if it doesn't need to be run at this hour
        if ($task.time -ne "hourly" -and $task.time -notcontains $now.Hour) { continue }

        #escape spaces in the path
        $path = $task.path.Replace(" ", "`` ")

        #write the name of the task to the console
        write-host "`t$($task.name)"

        #use a try block so an uncaught exception in the task doesn't kill the scheduler
        try {

            #run the task on the local machine
            if ($task.remote -eq $false) { Invoke-Expression $path }
        
            #run the task on a remote machine
            else {

                #start a session on the remote machine
                $session = New-PSSession -ComputerName $task.remote

                #define the script block that runs the task
                $block = {
                    param($path)
                    Invoke-Expression $path
                }

                #run the task!
                Invoke-Command -Session $session -ScriptBlock $block -ArgumentList $path
            }
        }
        
        #if the task failed, write error message to console
        catch {
            write-host "`t`tFailed to run." -ForegroundColor Red
            write-host '`t`t$($_.ErrorDetails.Message)' -ForegroundColor Red
        }

        #end the session on the remote machine
        if ($task.remote -ne $false) {
            Remove-PSSession -Session $session
        }
    }
}