Scheduler (cron jobs)
The scheduler module runs in-process actions on a recurring schedule written as a CRON-like expression. You register one or more jobs with a JobManager, start it, and each job's delegate runs on its own thread whenever the current minute matches its expression. Use it for lightweight periodic work inside a long-running process, such as cleanup, polling or cache refresh.
This module is a minimal minute-resolution timer that lives in the core package. For queued, retried or persisted background work, use the separate BackgroundJobs package instead.
| Package | CrispyWaffle |
| Namespace | CrispyWaffle.Scheduler |
How it works
Timer. JobManager owns a System.Timers.Timer with a fixed 30-second interval. On each tick it compares the current minute (DateTime.Now.Minute) with the minute of the last run. If the minute has changed, it records the new run time and calls Execute(DateTime.Now) on every registered JobRunner, in the order they were added. The last-run time is initialized when the JobManager is constructed, so the minute in which you create the manager never triggers jobs. All times are local (DateTime.Now).
Matching. Each JobRunner holds a CronScheduler built from its expression. Execute checks CronScheduler.IsTime(dateTime); if the time does not match, nothing happens.
Threading. When the time matches, the runner starts a new Thread for the delegate, unless the previous run of the same job is still in the Running or WaitSleepJoin state, in which case the run is skipped. Runs of the same job never overlap; different jobs run concurrently. Calls to Execute are serialized with a lock per runner. The job threads are ordinary foreground threads and nothing catches exceptions thrown by the delegate: an unhandled exception in a job terminates the process. Wrap the body of each job in try/catch.
Stopping. Stop() stops the timer, so no new runs start. Jobs already running are not interrupted. JobManager does not implement IDisposable.
CRON syntax
CronScheduler supports five fields, in this order. There is no seconds field and no year field.
| Position | Field | Values | * expands to |
|---|---|---|---|
| 1 | Minute | 0-59 | 0-59 |
| 2 | Hour | 0-23 | 0-23 |
| 3 | Day of month | 1-31 | 1-31 |
| 4 | Month | 1-12 | 1-12 |
| 5 | Day of week | 0-6, 0 = Sunday | 0-6 |
Fields are found by pattern matching, not by splitting on spaces. The expression is scanned with CronSchedulerValidations.ValidationRegex, and each successive match becomes the next field. Any character that is not part of a match acts as a separator. Fields that are missing at the end default to *, so 10 is the same as 10 * * * * and * */3 is the same as * */3 * * *. Matches after the fifth are ignored.
Each field can use exactly one of these forms:
| Form | Example | Meaning |
|---|---|---|
* |
* |
Every value in the field's range. |
*/n |
*/15 |
Every value in the field's range that is divisible by n (value % n == 0). |
a-b |
10-15 |
Every value from a to b, inclusive. |
a-b/n |
10-30/2 |
Every value from a to b, inclusive, that is divisible by n. |
a |
5 |
A single value. |
a,b,c |
0,6 |
A list of single values. |
All conditions are combined with AND: a time matches only if the minute, hour, day of month, month and day of week all match. This differs from standard cron, where a restricted day of month and a restricted day of week are combined with OR.
Behaviors to be aware of:
- Steps are divisibility tests, not offsets from the start.
*/5in the day-of-month field yields 5, 10, 15, 20, 25, 30 (not 1, 6, 11, ...).1-10/3in the minute field yields 3, 6, 9 (not 1, 4, 7, 10).*/2in the month field yields 2, 4, ..., 12. - Not supported: names (
MON,JAN),?,L,W,#,7for Sunday, steps on a single value (5/10), lists that contain ranges (1-5,10), and macros such as@daily. - Unsupported tokens are not rejected. They are skipped or split into extra matches, which shifts the remaining fields. For example,
0 0 * * MONhas only four matches, so the day of week becomes*and the job runs every day at midnight.1-5,10 * * * *yields six matches (1-5,10,*, ...), so10becomes the hour. - Values outside a field's range are accepted but never match (for example, minute
70). IsValidreturnstrueif the expression contains at least one match, so it only rejects strings with no digits and no*at all.
| Expression | Runs |
|---|---|
* |
Every minute. |
10 |
At minute 10 of every hour. |
*/10 |
At minutes 0, 10, 20, 30, 40 and 50 of every hour. |
10 1 * * 0 |
At 01:10 every Sunday. |
0 9-17 * * 1-5 |
On the hour from 09:00 to 17:00, Monday to Friday. |
*/20 * 10,20,30 * * |
At minutes 0, 20 and 40 of every hour on days 10, 20 and 30 of each month. |
0 0 1 * * |
At midnight on the first day of each month. |
Getting started
using System;
using System.Threading;
using CrispyWaffle.Scheduler;
var counter = 0;
var jobManager = new JobManager();
jobManager.AddJob("* * * * *", () => Interlocked.Increment(ref counter));
jobManager.Start();
Thread.Sleep(TimeSpan.FromMinutes(2));
jobManager.Stop();
Console.WriteLine(counter); // typically 1 or 2, depending on when the manager was created
Reference
JobManager
public class JobManager : IJobManager — holds a list of jobs and triggers them from a 30-second timer, at most once per minute.
| Member | Description |
|---|---|
JobManager() |
Creates the manager and its timer. The timer is not started. |
AddJob(string cronExpression, ThreadStart action) |
Creates a JobRunner for the expression and action and adds it. Throws ArgumentNullException if the expression is null or whitespace or the action is null, ArgumentOutOfRangeException if the expression is not valid. The job list is not thread-safe; add jobs before calling Start. |
Start() |
Starts the timer. |
Stop() |
Stops the timer. Running jobs continue to completion. |
IJobManager
public interface IJobManager — contract for a component that schedules and runs jobs.
| Member | Description |
|---|---|
AddJob(string cronExpression, ThreadStart action) |
Adds a job with a schedule and the delegate to run. |
Start() |
Begins running scheduled jobs. |
Stop() |
Stops triggering jobs until Start is called again. |
IJobRunner
public interface IJobRunner — contract for a single scheduled job.
| Member | Description |
|---|---|
Execute(DateTime dateTime) |
Runs the job if dateTime matches its schedule. |
JobRunner
public class JobRunner : IJobRunner — runs one ThreadStart delegate on a new thread when a CronScheduler matches.
You normally create these through JobManager.AddJob. Create one directly to drive a schedule from your own timer.
| Member | Description |
|---|---|
JobRunner(string schedule, ThreadStart threadStart) |
Builds a CronScheduler from schedule. Throws ArgumentNullException if schedule is null or whitespace or threadStart is null, ArgumentOutOfRangeException if schedule is not a valid expression. |
Execute(DateTime dateTime) |
If dateTime matches the schedule and the previous run is not still running, starts the delegate on a new thread and returns immediately. Thread-safe. |
IScheduler
public interface IScheduler — contract for an object that parses a schedule expression and tests dates against it.
| Member | Description |
|---|---|
IsValid(string expression) |
Returns true if the expression is acceptable to the scheduler. |
IsTime(DateTime dateTime) |
Returns true if dateTime matches the schedule. |
Minutes |
ICollection<int> of matching minutes. |
Hours |
ICollection<int> of matching hours. |
DaysOfMonth |
ICollection<int> of matching days of the month. |
Months |
ICollection<int> of matching months. |
DaysOfWeek |
ICollection<int> of matching days of the week. In CronScheduler these use System.DayOfWeek numbering: 0 = Sunday through 6 = Saturday. |
CronScheduler
public class CronScheduler : IScheduler — parses a five-field expression (see CRON syntax) into sets of matching values.
| Member | Description |
|---|---|
CronScheduler() |
Creates an instance with no expression. All collections are empty and IsTime always returns false. Useful only for calling IsValid. |
CronScheduler(string expression) |
Parses expression. Throws ArgumentOutOfRangeException if IsValid returns false, ArgumentNullException if expression is null. |
IsValid(string expression) |
Returns true if ValidationRegex finds at least one match in expression. This is a weak check; see CRON syntax. |
IsTime(DateTime dateTime) |
Returns true if the minute, hour, day, month and day of week of dateTime are all in their sets. Seconds are ignored. |
Minutes, Hours, DaysOfMonth, Months, DaysOfWeek |
The parsed sets, as ICollection<int>. They are the live internal sets; do not modify them. |
CronSchedulerValidations
public static class CronSchedulerValidations — the regular expressions used to tokenize and classify CRON fields.
All are public static readonly Regex, compiled, with a 5-second match timeout.
| Member | Pattern | Matches |
|---|---|---|
DividedRegex |
(\*/\d+) |
*/n |
RangeRegex |
(\d+\-\d+)\/?(\d+)? |
a-b and a-b/n |
WildRegex |
(\*) |
* |
ListRegex |
(((\d+,)*\d+)+) |
a and a,b,c |
ValidationRegex |
The four above joined with \|, in that order |
Any single field. Used to split an expression into fields. |
When classifying a field, CronScheduler tests DividedRegex, then RangeRegex, then WildRegex, then ListRegex, and uses the first that matches.
Examples
Several jobs with error handling
using System;
using CrispyWaffle.Log;
using CrispyWaffle.Scheduler;
var jobManager = new JobManager();
jobManager.AddJob("*/5", () =>
{
try
{
PurgeExpiredSessions();
}
catch (Exception ex)
{
LogConsumer.Handle(ex);
}
});
jobManager.AddJob("0 3 * * 0", () =>
{
try
{
RebuildSearchIndex(); // 03:00 every Sunday
}
catch (Exception ex)
{
LogConsumer.Handle(ex);
}
});
jobManager.Start();
Inspecting a schedule
using System;
using CrispyWaffle.Scheduler;
var scheduler = new CronScheduler("* 10-15 * * *");
Console.WriteLine(string.Join(",", scheduler.Hours)); // 10,11,12,13,14,15
var weekend = new CronScheduler("* * * * 0,6");
Console.WriteLine(weekend.IsTime(new DateTime(2020, 9, 5, 12, 0, 0))); // True (Saturday)
Driving a runner from your own clock
using System;
using CrispyWaffle.Scheduler;
IJobRunner runner = new JobRunner("0 * * * *", () => Console.WriteLine("top of the hour"));
runner.Execute(new DateTime(2024, 1, 1, 10, 0, 0)); // starts the job
runner.Execute(new DateTime(2024, 1, 1, 10, 1, 0)); // does nothing