Coravel Scheduler: Scheduling and Monitoring Tasks in ASP.NET Core
Contents
Coravel's scheduler runs scheduled tasks inside your ASP.NET Core process: you register it with AddScheduler(), define each task in code with UseScheduler(), and set when it runs with methods such as .EveryMinute(), .DailyAt(2, 0), or .Cron("0 2 * * *"), evaluated in UTC unless you call .Zoned(...). Everything lives in memory. Coravel doesn't persist tasks, doesn't replay runs missed while the app was down, runs every task on every instance, and logs nothing about a failed task unless you configure it to. This guide covers Coravel 6.0.2, the latest release on NuGet (checked October 25, 2026), and reads its source at that release where the docs are silent.
Set Up Coravel's Scheduler in ASP.NET Core#
dotnet add package Coravel --version 6.0.2Coravel 6.0.2 targets .NET 6; the examples below use C# 12 primary constructors, so they need .NET 8 or later. The task is an invocable, a class implementing IInvocable, which is the form the scheduler docs recommend:
// Program.cs
using Coravel;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScheduler();
builder.Services.AddTransient<NightlyCleanup>(); // Invocables must be registered.
var app = builder.Build();
app.Services.UseScheduler(scheduler =>
{
scheduler.Schedule<NightlyCleanup>()
.Cron(NightlyCleanup.Schedule)
.PreventOverlapping(nameof(NightlyCleanup));
})
.OnError(exception =>
app.Logger.LogError(exception, "A Coravel scheduled task failed."));
app.MapGet("/", () => "OK");
app.Run();// NightlyCleanup.cs
using Coravel.Invocable;
public sealed class NightlyCleanup(ILogger<NightlyCleanup> logger) : IInvocable, ICancellableInvocable
{
public const string Schedule = "0 2 * * *"; // 02:00 UTC
// Coravel sets this before each run and cancels it when the app stops.
public CancellationToken CancellationToken { get; set; }
public async Task Invoke()
{
int deleted = await DeleteExpiredSessionsAsync(CancellationToken);
logger.LogInformation("Deleted {Count} expired sessions.", deleted);
}
// Simulated work that observes the token.
private static async Task<int> DeleteExpiredSessionsAsync(CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
return 42;
}
}Three details from the source:
- Each run gets its own DI scope. Coravel creates a scope and resolves the invocable from it on every run (ScheduledEvent.cs), so injecting a scoped EF Core
DbContextis safe. - A missing registration fails at run time, not at startup. The resolve happens inside the per-run
try, so forgettingAddTransient<NightlyCleanup>()turns every occurrence into an exception that only the error handling described below reports. - A malformed expression fails at startup.
UseSchedulerruns your callback immediately (SchedulerServiceRegistration.cs), and.Cron(...)validates its string there.
For an async lambda instead of an invocable, use ScheduleAsync(...). Passed to Schedule(), it compiles as an async void action Coravel can't await, which the docs warn against.
How Coravel Decides When a Task Runs#
AddScheduler() registers a hosted service that starts a one-second timer once the application has started (SchedulerHost.cs). On each tick:
- Minute-level schedules are checked once a minute. Every interval method from
EveryMinute()up, and every.Cron(...)task, is evaluated only when the UTC second is 0. Seconds-based tasks (EverySecond()throughEverySeconds(n)) are evaluated on every tick (Scheduler.cs). - Late ticks are replayed. If the timer falls behind under load, Coravel runs each missed second in order. Set
Coravel:Schedule:LogTickCatchUptotrueto log the catch-up. - Tasks due in the same tick run one after another. A slow task delays the others on its worker.
scheduler.OnWorker("reports")gives the tasks registered after it their own thread. - Each tick runs independently. A task still running when it's due again starts a second copy, unless it has
.PreventOverlapping(...).
Two modifiers change whether a due task runs. .When(async () => ...) checks a predicate and skips the occurrence when it returns false. .RunOnceAtStart() also runs the task on the first tick after startup, whatever its schedule.
The interval methods are shorthand for cron expressions (ScheduledEvent.cs):
| Method | Expression, in UTC unless zoned |
|---|---|
EveryMinute() |
* * * * * |
EveryFiveMinutes() to EveryThirtyMinutes() |
*/5 * * * *, */10 * * * *, */15 * * * *, */30 * * * * |
Hourly(), HourlyAt(15) |
0 * * * *, 15 * * * * |
Daily(), DailyAtHour(13), DailyAt(13, 30) |
0 0 * * *, 0 13 * * *, 30 13 * * * |
Weekly() |
0 0 * * 1: Monday at midnight |
Monthly() |
0 0 1 * * |
.Weekday(), .Monday(), and the other days |
Add to the day-of-week field: .DailyAt(9, 0).Weekday() is 0 9 * * 1,2,3,4,5 |
EverySecond() to EverySeconds(n) |
No cron equivalent |
Coravel Reads a Subset of Cron#
Coravel parses cron itself. It accepts five space-separated fields containing *, numbers, lists (1,2,3), ranges (1-5), ranges with a step (0-30/5), and */n. It throws MalformedCronExpressionException for names such as MON-FRI, for ?, L, W, or #, for a sixth field, and for a field that mixes a list with a range, such as 1-5,10 (CronExpression.cs, CronExpressionComplexPart.cs). Some expressions it accepts mean something different elsewhere:
| Expression | Coravel 6.0.2 runs | cronie runs | CronRadar expects |
|---|---|---|---|
0 0 */2 * * |
Even days: 2nd, 4th, ... 30th | Odd days: 1st, 3rd, ... 31st | Odd days |
0 0 1 */3 * |
March, June, September, December | January, April, July, October | January, April, July, October |
0 */5 * * * |
05:00, 10:00, 15:00, 20:00 | Also 00:00 | Also 00:00 |
0 9 * * 7 |
Never | Sundays | Rejects the schedule |
0 9 1-7 * 1 |
The first Monday of the month | The 1st through 7th and every Monday | The first Monday of the month |
The step rows come from how Coravel evaluates */n: a value matches when it divides evenly by n, with 0 counted as the field's size, 60 for minutes or 24 for hours (CronExpressionPart.cs). In cronie's crontab(5), * stands for "first-last", so a step counts from the field's first value. Minute and hour steps that divide 60 or 24 evenly, such as */15 or */6, mean the same everywhere. For any other step, write its start: 0 0 1-31/2 * *, 0 0 1 1-12/3 *, and 0 0-23/5 * * * mean the same to Coravel, cronie, and CronRadar.
Coravel compares the day-of-week field with .NET's DayOfWeek, which runs 0 to 6, so use 0 for Sunday. In the last row Coravel and CronRadar agree against cronie: Coravel requires all five fields to match (CronExpression.cs), as CronRadar does when both day fields are restricted. An expression that avoids these differences, on a task that isn't zoned, works unchanged for both, which is why NightlyCleanup keeps it in one constant.
Time Zones: UTC Unless You Call Zoned()#
Coravel evaluates every schedule in UTC by default. .Zoned(TimeZoneInfo) makes a single task follow a zone's wall clock:
scheduler.Schedule<SendDailyDigest>()
.DailyAt(8, 0)
.Zoned(TimeZoneInfo.FindSystemTimeZoneById("America/New_York"));On .NET 6 and later, FindSystemTimeZoneById accepts IANA IDs such as America/New_York on Windows too, unless NLS or globalization-invariant mode is enabled (TimeZoneInfo docs). The TimeZoneInfo.Local in Coravel's example ties the schedule to whatever zone the server or container happens to be set to.
Coravel's docs warn only of "unexpected behavior" around daylight saving time. The source shows what it is: each minute, Coravel converts the current UTC time into the task's zone and matches the expression against the local wall-clock time (ScheduledEvent.cs, ZonedTime.cs). A local time that doesn't exist on the night clocks spring forward never matches, so a 02:30 New York task doesn't run that day. A local time that occurs twice when clocks fall back matches twice, so a 01:30 task runs twice. Schedule zoned tasks outside the hour your zone skips or repeats, or keep them in UTC. Cron daylight saving time compares how other schedulers handle the change.
What Coravel Does Not Do#
- Persist anything. Tasks live in an in-memory dictionary that your
UseSchedulercallback fills at every startup (Scheduler.cs). Delete the line and the task is gone, with no record that it existed. - Catch up after downtime. Coravel replays ticks it missed while running, but its tick tracker starts from the current time when the app starts (SchedulerHost.cs). A 02:00 run that falls during a deploy or a crash never happens. Missed runs after downtime covers what other schedulers do and how to add a watermark.
- Coordinate instances. Every instance runs every task. Asked about Kubernetes replicas, the maintainer answered, "Each instance will execute the schedules." A rolling deploy that starts the new instance before stopping the old one briefly runs two copies, too.
- Lock across processes or retry. The README's FAQ answers no to distributed locking, suggesting the DistributedLock library inside
Invoke(), and no to retries, suggesting Polly. - Run while the process doesn't. The scheduler is a hosted service in your web app. IIS, for example, shuts down a worker process after 20 idle minutes by default (processModel).
PreventOverlapping Guards One Process, for Up to 24 Hours#
.PreventOverlapping("key") takes a lock named by the key before a run and releases it in a finally afterward. If the lock is held, Coravel skips that occurrence and logs nothing (Scheduler.cs). The key must be unique among your tasks. Two limits:
- The lock is in memory.
AddScheduler()registers anInMemoryMutex(SchedulerServiceRegistration.cs), which a second instance never sees. - The lock expires after 24 hours. Coravel takes it with a 1,440-minute timeout, so a run that lasts longer no longer blocks the next one (issue #351).
A lock in shared storage stops two instances running a task at the same time, but not one after the other: an instance whose tick lands just after the first run finished takes the free lock and runs again. To run once per occurrence, also record each occurrence you complete under a unique key. Prevent overlapping cron jobs compares lock types, and distributed cron covers the ways to run a job once across servers.
Exceptions Are Caught, and by Default Nobody Hears About Them#
When a task throws, Coravel catches the exception and moves on to the next task. What else happens depends on configuration (Scheduler.cs):
- With
.OnError(...), Coravel calls your handler with the exception. The handler isn't told which task failed; the stack trace is your clue. - With
.LogScheduledTaskProgress(), Coravel logs the exception at Error level, inside a log scope naming the invocable type for an invocable, and logs each start and finish at Debug. The method changed in 6.0.0 and no longer takes a logger argument. - With neither, the exception is discarded and nothing is logged.
Use one of the two; with both, a logging OnError handler records every failure twice. Neither helps with a task that never runs: a process that's down or unloaded for being idle, a When predicate that always returns false, or a task deleted in a refactor raises no exception and writes no log line.
When Coravel Is the Right Choice#
Coravel fits an app on one instance, or work that's harmless to run on every instance, where an occurrence lost to a deploy costs little and you want schedules next to the code with no storage to operate. Once a task must run once across instances, catch up after downtime, or retry from persisted state, you need a scheduler with shared storage. Hangfire vs. Quartz.NET vs. BackgroundService weighs those options, and for a single task a BackgroundService loop does the same job with no dependency.
Monitor Coravel Tasks With CronRadar#
CronRadar has no Coravel integration and does not discover Coravel tasks. Each task reports for itself, through the base .NET SDK, CronRadar 1.0.13 on NuGet (checked October 25, 2026), or through HTTP check-ins. Nothing compares Coravel's task list with CronRadar's, so a task you delete from UseScheduler leaves a monitor that keeps expecting runs; pause or delete it too.
dotnet add package CronRadar --version 1.0.13Program.cs stays as it is. The invocable wraps its work:
// NightlyCleanup.cs
using Coravel.Invocable;
public sealed class NightlyCleanup(ILogger<NightlyCleanup> logger) : IInvocable, ICancellableInvocable
{
public const string Schedule = "0 2 * * *"; // 02:00 UTC, read the same by Coravel and CronRadar
public CancellationToken CancellationToken { get; set; }
public async Task Invoke()
{
// A per-run limit turns a hang into a reported failure.
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(30));
// The class shares its namespace's name, so qualify it fully.
await CronRadar.CronRadar.WrapAsync("nightly-cleanup", async () =>
{
int deleted = await DeleteExpiredSessionsAsync(timeout.Token);
logger.LogInformation("Deleted {Count} expired sessions.", deleted);
}, schedule: Schedule);
}
// Simulated work that observes the token.
private static async Task<int> DeleteExpiredSessionsAsync(CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
return 42;
}
}The SDK reads its key from the CRONRADAR_API_KEY environment variable. Supply it from your secret store, with a separate CronRadar application and key for each environment. What the wrapper does:
- Reports
start, thencompleteorfail. On failure it sends the exception message and rethrows the original exception, so Coravel'sOnErrorstill logs it. Afailalerts immediately. - Never breaks the task. Each SDK request times out after 5 seconds, and monitoring errors are caught instead of thrown.
- Creates the monitor. The
schedulecreates it on the first report and recreates it if it's deleted. Schedule and grace period apply only when a report creates the monitor, so change them later in the dashboard, not in the constant; the SDK has no per-call grace period argument. - Catches the runs that never happen. If no report arrives, the monitor goes
warningonce 02:00 UTC passes, andcritical, with an alert, once it is past the monitor's grace period and at least two minutes late. That covers an app that was down, unloaded, or mid-deploy at 02:00. - Flags a stuck run, eventually. Every report, including
start, moves the expected time to the task's next occurrence. If that time plus the grace period passes while the last report is astart, the monitor goescriticalas stuck. For a nightly task that's the next night, and the next run'sstartresets the window. There's no per-job maximum runtime, which is whyInvoke()sets its own 30-minute limit.
At shutdown, Coravel cancels CancellationToken and waits for running tasks. A run that observes the token ends with an OperationCanceledException, which the wrapper reports as a failure: accurate, but a deploy during a run will alert.
Report From Inside Invoke()#
A report inside Invoke() fires only for runs that happen, which decides how each Coravel feature looks to CronRadar:
PreventOverlapping: a skipped occurrence sends nothing. The run still in progress covers it if it finishes before the next occurrence plus the grace period; if it runs longer, the monitor goescriticalas stuck, which is the right alert for a run that overran.When(...): a skipped occurrence also sends nothing, and CronRadar treats it as missed. If skips are normal, move the check inside the wrapped delegate and return early, so every scheduled occurrence reports a completion.RunOnceAtStart(): the extra run reports like any other and raises no alert.- Several instances: every instance that runs the task reports. Put
WrapAsyncinside the branch that won the lock, so only the instance that did the work reports.
Send CronRadar the UTC Equivalent#
CronRadar reads standard five-field cron in UTC. For a task that isn't zoned, use the expression from the method table, adjusted for the differences above. For a zoned task, convert: .DailyAt(8, 0).Zoned(...) for New York runs at 13:00 UTC in winter and 12:00 UTC in summer. Send the earlier, 0 12 * * *, and set the monitor's grace period in the dashboard to at least an hour plus the run's usual duration. After the first run, compare the monitor's next expected run with when Coravel will next run the task.
Seconds-based tasks have no five-field form, and CronRadar rejects a seconds field, so report such a task at most once a minute against * * * * *. Budget requests too: each wrapped run sends two, and check-ins are limited to 60 a minute and 1,000 an hour per API key. The hourly limit is about 16 a minute, or eight wrapped every-minute tasks. A check-in the limit rejects isn't recorded and looks like a missed run.
Without the SDK: HTTP Check-Ins#
You can send the SDK's three requests yourself: a POST to https://cron.life/api/v1/ping/nightly-cleanup/start before the work, then to /complete or /fail. Use the API key as the Basic-auth username with an empty password, pass the URL-encoded schedule as a query parameter, time each request out after 5 seconds, and catch every monitoring error so the task's own exception still reaches Coravel. HTTP also lets you set gracePeriod per monitor on the check-in that creates it, and send bounded outcomes on /complete for expectations you configure, which the 1.0.13 SDK can't. The API reference lists the parameters.
Frequently Asked Questions#
Does Coravel persist scheduled tasks?#
No. Coravel 6.0.2 rebuilds tasks in memory from your UseScheduler code at every startup, so an occurrence that falls while the app is down never runs. Coravel Pro, a separate commercial product, lists database persisted scheduling among its features. Among open-source .NET schedulers, Hangfire and Quartz.NET with a database job store persist schedules.
Does Coravel run tasks on every server?#
Yes. Every instance that calls UseScheduler() runs every task, and PreventOverlapping locks only within one process. Run scheduled tasks on one dedicated instance, or take a distributed lock inside Invoke() and skip the run when another instance holds it.
How do I run a Coravel task in a specific time zone?#
Chain .Zoned(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin")) after the interval method; without it, Coravel uses UTC. A zoned task in the hour the clocks skip doesn't run that day, and one in the hour they repeat runs twice.
How do I catch exceptions in Coravel scheduled tasks?#
Chain .OnError(exception => ...) to UseScheduler(...) and log there, or call .LogScheduledTaskProgress(); without either, Coravel discards the exception. Catch inside Invoke() when you need the task's context or a retry, then rethrow so Coravel and your monitoring still see the failure.
Does CronRadar auto-discover Coravel tasks?#
No. CronRadar has no Coravel integration, and its .NET auto-discovery covers Hangfire and Quartz.NET only. Wrap each Coravel task with the base SDK's WrapAsync or send HTTP check-ins; the first report that carries a schedule creates the monitor.
Put this guide to work
Connect your .NET job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar 1.0.13. Scheduled heartbeat reporting is available; execution IDs, outcome values, and CRONRADAR_BASE_URL support in the repository reference require the pending 1.0.14 release.