This library provides a cross-platform service easily creating and running tasks on a schedule. It makes extensive use of NodaTime to allow for robust time zone handling, as well as Cronos for Cron expression support. Importantly, it is well architected to support testing, dependency injection and modern design methodologies.
The library is available via NuGet and is delivered via NuGet Package Manager:
Install-Package DevDecoder.Scheduling
If you are targeting .NET Core, use the following command:
dotnet add package
Install-Package DevDecoder.Scheduling
The package exposes several key interfaces, and implementations that allow for easily creating and running tasks on a schedule.
For example:
// Only create one scheduler per application, and dispose when finished with it.
using var scheduler = new Scheduler();
...
// Run a job 3 times with a gap of 5 seconds between each execution.
var job = scheduler.Add(state => Console.WriteLine($"Execution {++counter}, due: {state.Due:ss.fffffff}"),
new LimitSchedule(3, new GapSchedule(Duration.FromSeconds(5))));
// We can use the returned job, to execute manually or disable the job temporarily, etc..
job.IsEnabled = false;
Assert.False(job.IsExecuting);
The DevDecoder.Scheduling.IScheduler
interface is the main scheduler service, it is implemented by DevDecoder.Scheduling.Scheduler
concrete type. Note it also implements IDisposable
, so should be disposed when your application terminates.
You can create a new scheduler in the main entry point of your code:
using var scheduler = new Scheduler();
However, the library is designed to be used with a dependency injection framework. You can register the service as a singleton, and have it injected automatically into your other services, or retrieve it manually:
// Add as a singleton, accessible by its interface.
services.AddSingleon<IScheduler, Scheduler>();
...
// Retrieve manually from the service provider
var scheduler = serviceProvider.GetService<IScheduler>();
Modern DI frameworks should correctly handle instantiation and disposal automatically, as well as suppplying a logger if registered.
The scheduler retrieves the current time from an IPreciseClock
. There are 4 clocks provided, which should cover every eventuality but you can easily create your own if desired.
StandardClock
- This is equivalent to using the built inDateTime.UTCNow
function, which is usually accurate to ~100ns. It is the default choice, an suitable for most applications.FastClock
- This uses the Query Performance Counters to get the most accurate timestamp, however, it is not synchronized to any external source, though it is often accurate to <100 clock cycles. On some systems, the clock is not available (seeFastClock.IsAvailable
, and so defaults to theStandardClock
). It is rare that this clock is necessary.SynchronizedClock
- This clock uses GetSystemTimePreciseAsFileTime to get an accurate, synchronized time, where available. This is recommended for scenarios where it is important for multiple machines to stay in synch, e.g. during networking scenarios.TestClock
- This clock can be used during testing to allow you to control what times are returned when querying. It accepts a function that, when given anInstant
returns the nextInstant
. Two static functionsFixed
, which supplies a clock that returns the sameInstant
every time, andFrom
which provides a clock that returns anInstant
from a specific time, and increments by a setDuration
each time it is queried, are supplied for convenience. There is alsoTestClock.Never
which always returnsInstant.MaxValue
(the 'end of time'!).
All clocks have a static Instance
property that can get their singleton implementation, and this can be supplied directly to the Scheduler
on creation:
// Use the Synchronized clock.
using var scheduler = new Scheduler(SynchronizedClock.Instance);
However, you can also specify the ClockPrecision
enumeration, e.g.
// Use the Synchronized clock.
using var scheduler = new Scheduler(ClockPrecision.Synchronized);
Using dependency injection:
// Add the clock singleton to the services collection.
services.AddSingleton<IPreciseClock>(SynchronizedClock.Instance);
// Add as a singleton, accessible by its interface.
services.AddSingleon<IScheduler, Scheduler>();
The schedule uses NodaTime
to ensure it handles timezones accurately. To that end it can be injected with an IDateTimeZoneProvider
on creation.
The Scheduler
constructor accepts an ILogger<Scheduler>
for logging, this is normally injected via dependency injection, but here is an example of a simple console logger.
The MaximumExecutionDuration
can also be specified during creation, or via the IScheduler
interface. This defaults to Scheduler.DefaultMaximumDuration
(which is 10 mins), but you can set this to any duration (including Duration.MaxValue
) at any time (including during construction). It will set any job, that doesn't have the ScheduleOptions.LongRunning
flag set, to cancel after the duration has elapsed. As such, if you do have a job that might take longer, you must set the ScheduleOptions.LongRunning
flag on the schedule.
The scheduler runs jobs on a schedule. These can be as complex as your imagination allows, so long as they implement ISchedule
, in particular:
ZonedDateTime? Next(IScheduler scheduler, ZonedDateTime last);
That is, given the last time a job started, or completed (based on whether the ScheduleOptions.FromDue
flag is set for the schedule), it needs to return the next time the job should execute. On first execution, or first running after being disabled, this will be the current date and time.
The following schedules are built in for convenience:
The one off schedule allows a task to run once, at a fixed date and time, e.g.
// Execute at midday (UTC) on 1st January 2023
new OneOffSchedule(new ZonedDateTime(Instant.FromUtc(2023, 1, 1, 12, 0), DateTimeZone.Utc));
The gap schedule allows a task to run repeatedly, with a fixed interval. The interval can be measured from the start of the proceeding execution, or from it's conclusion, using the ScheduleOptions.FromDue
flag, e.g.
// Execute with a 5 second gap between the start of each invocation.
new GapSchedule(Duration.FromSeconds(5), ScheduleOptions.FromDue);
The functional schedule is a convenience class that accepts a lambda to calculate the next date/time, e.g.
// Execute every 10 seconds (rounded up to nearest second).
new FunctionalSchedule(t => t.PlusSeconds(10), ScheduleOptions.AlignSeconds | ScheduleOptions.FromDue);
The limit schedule wraps any schedule, limiting how many times it will execute, e.g.:
// Execute 3 times, with a 5ms gap between each execution.
new LimitSchedule(3, new GapSchedule(Duration.FromMilliseconds(5)))
The aggregate schedule is extremely powerful as it allows you to combine multiple schedules together, e.g.:
// Execute every 5th second (aligned) and every 3rd second (for the first 3 times).
new AggregateSchedule(
new GapSchedule(Duration.FromSeconds(5), ScheduleOptions.AlignSeconds),
new LimitSchedule(3,
new FunctionalSchedule(t => t.PlusSeconds(3), ScheduleOptions.AlignSeconds)));
Finally, we also expose a schedule that can accept any chron expression, e.g.:
// Execute every 2 minutes from 1:00 AM to 01:15 AM and from 1:45 AM to 1:59 AM and at 1:30 AM
new CronSchedule("30,45-15/2 1 * * *")
// Note we also support the including the optional seconds format:
new CronSchedule("0 30,45-15/2 1 * * *", CronFormat.IncludeSeconds)
Every schedule also exposes the ScheduleOptions
flags which have the following meanings:
Flag | Effect when set |
---|---|
LongRunning | The job will not be limited by the MaximumExecutionDuration . |
IgnoreErrors | The job will not be disabled when an exception is thrown. |
FromDue | The ISchedule.Next method will be called with the time the previous execution was due to start, rather than when it finished. |
AlignSeconds | The result of ISchedule.Next will be rounded up to the nearest second by the scheduler. |
AlignMinutes | The result of ISchedule.Next will be rounded up to the nearest minute by the scheduler. |
AlignHours | The result of ISchedule.Next will be rounded up to the nearest hour by the scheduler. |
AlignDays | The result of ISchedule.Next will be rounded up to the nearest midnight by the scheduler. |
Any object that implements the simple IJob
interface can be passed to the scheduler for scheduling:
public interface IJob
{
/// <summary>
/// An optional job name.
/// </summary>
public string Name { get; }
/// <summary>
/// Executes the current job.
/// </summary>
/// <param name="state">Information regarding the current job, (see <see cref="IJobState" />).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An awaitable task.</returns>
Task ExecuteAsync(IJobState state, CancellationToken cancellationToken = default);
}
However, there is also a convenient SimpleJob
class that allows the creation of any job from an action or function, using one of the SimpleJob.Create
or SimpleJob.CreateAsync
overloads. Most conveniently though, there are numerouse extension methods on IScheduler
that overload the Add
and AddAsync
methods to create a SimpleJob
automatically, e.g.:
// Run a simple function in 10s, logging it's result on completion.
scheduler.Add(state => $"Log this result {state.Due}",
new OneOffSchedule(scheduler.GetCurrentZonedDateTime().PlusSeconds(10)));
Although the scheduling system is really designed to run actions, if you supply SimpleJob
with a function, it will log any result returned, e.g.
[2022-11-07 18:50:41Z] info: DevDecoder.Scheduling.Scheduler[0]
The 'state => $"Log this result {state.Due}"' job returned: Log this result 2022-11-07T18:50:41 GMT Standard Time (+00)
You will also note that jobs are automatically named based on the arguments passed into the function/action, for easier debugging.
When a job is executed it is passed an IJobState
and a CancellationToken
. The latter of these should be respected to allow for easy termination of overdue jobs. The first allows the execution function access to lots of useful information about the current execution, and allows the job to disable itself, preventing further execution.
/// <summary>
/// Holds information for the currently executing <see cref="IJob">job</see>.
/// </summary>
public interface IJobState
{
/// <summary>
/// A unique identified for the scheduled job.
/// </summary>
Guid Id { get; }
/// <summary>
/// An optional job name.
/// </summary>
string Name { get; }
/// <summary>
/// The <see cref="IScheduler">scheduler</see> executing this job.
/// </summary>
IScheduler Scheduler { get; }
/// <summary>
/// The <see cref="ISchedule">schedule</see> that triggered this execution.
/// </summary>
/// <remarks>Will be <c>null</c> if the job was <see cref="IsManual">executed manually</see>.</remarks>
ISchedule? Schedule { get; }
/// <summary>
/// The <see cref="Instant">instant</see> the job was due to run.
/// </summary>
/// <remarks>This will be when the job was requested.</remarks>
ZonedDateTime Due { get; }
/// <summary>
/// The current logger, if any; otherwise <c>null</c>.
/// </summary>
ILogger? Logger { get; }
/// <summary>
/// If <c>true</c> then the current execution was triggered manually; otherwise <c>false</c>.
/// </summary>
bool IsManual { get; }
/// <summary>
/// If <c>true</c> then the job is currently executing; otherwise <c>false</c>.
/// </summary>
bool IsExecuting { get; }
/// <summary>
/// If <c>true</c> then the job is allowed to execute; otherwise <c>false</c>, prevents further executions.
/// </summary>
bool IsEnabled { get; set; }
}
Similarly, when a job is added to the IScheduler
it returns an IScheduledJob
. This is almost identical to IJobState
, also allowing control over whether the job is enabled, but also allowing for manual execution of the job.
Note: A job will never be executed concurrently with itself. If a job is executed manually, whilst it is also executing as part of a schedule, the manual execution will receive the same task, and vice-versa. It is effectively 'debounced', meaning that a job execution is inherently thread-safe.
As well as disabling a job using IScheduledJob.IsEnabled
, you can permanently remove it from the scheduler using IScheduler.TryRemove
. A removed job can still be executed manually, but it's Scheduler
and Due
properties will always be null
and IsEnabled
will always be false
. You cannot re-add a removed job, you must re-create a job using the Add
or AddAsync
methods. As such, if you want to temporarily 'remove' a job, use the IsEnabled
property instead.
- Explicit schedule serialization support.
- Unit tests can be found in the
DevDecoder.Scheduling.Test
project.
- https://github.com/webappsuk/CoreLibraries/tree/master/Scheduling - The original library which I created whilst working at Web Applications UK and inspired this work, but is now largely abandoned. However, this project was built from the ground up to take advantage of the many changes to the wider eco-system in the last decade.
- https://github.com/HangfireIO/Cronos - Cronos is used to parse Cron expressions.
- https://github.com/nodatime/nodatime - The definitive answer to accurate dates and times in .NET!