A type-safe, developer-friendly wrapper around Quartz.NET for .NET 8+ that makes job scheduling intuitive — declaratively via attributes or dynamically at runtime.
SW.Scheduler is split into focused packages so each project only takes the dependencies it needs.
| Package | NuGet | Use in |
|---|---|---|
SW.Scheduler.Sdk |
SimplyWorks.Scheduler.Sdk |
Projects that define jobs (lightweight, no Quartz dependency) |
SW.Scheduler |
SimplyWorks.Scheduler |
Host/startup project (in-memory Quartz store) |
SW.Scheduler.EfCore |
SimplyWorks.Scheduler.EfCore |
Host project — adds EF Core model + job execution monitoring |
SW.Scheduler.PgSql |
SimplyWorks.Scheduler.PgSql |
Host project — PostgreSQL persistent Quartz store |
SW.Scheduler.SqlServer |
SimplyWorks.Scheduler.SqlServer |
Host project — SQL Server persistent Quartz store |
SW.Scheduler.MySql |
SimplyWorks.Scheduler.MySql |
Host project — MySQL/MariaDB persistent Quartz store |
SW.Scheduler.Viewer |
SimplyWorks.Scheduler.Viewer |
Host project — built-in HTMX admin UI (optional) |
Rule of thumb: projects that only define jobs reference
SW.Scheduler.Sdk. Only the startup/host project references a provider package (PgSql,SqlServer, orMySql), which pulls inSW.SchedulerandSW.Scheduler.EfCoretransitively.
In projects that define jobs:
dotnet add package SimplyWorks.Scheduler.SdkIn your host project — pick one provider:
# PostgreSQL (most common)
dotnet add package SimplyWorks.Scheduler.PgSql
dotnet add package SimplyWorks.Scheduler.EfCore
# SQL Server
dotnet add package SimplyWorks.Scheduler.SqlServer
dotnet add package SimplyWorks.Scheduler.EfCore
# MySQL / MariaDB
dotnet add package SimplyWorks.Scheduler.MySql
dotnet add package SimplyWorks.Scheduler.EfCore
# In-memory only (development / testing)
dotnet add package SimplyWorks.SchedulerOptionally — add the built-in admin UI:
dotnet add package SimplyWorks.Scheduler.ViewerThe admin UI is entirely optional. If you prefer to build your own dashboard, skip this package and inject
ISchedulerViewerQueryorIScheduleReaderdirectly into your own controllers. See Building a Custom UI below.
// MyApi/Jobs/DailyReportJob.cs
// This project only needs SW.Scheduler.Sdk
using SW.Scheduler;
[Schedule("0 0 8 * * ?", Description = "Daily report at 8 AM")]
[RetryConfig(MaxRetries = 3, RetryAfterMinutes = 5)]
public class DailyReportJob : IScheduledJob
{
private readonly IReportService _reports;
public DailyReportJob(IReportService reports) => _reports = reports;
public async Task Execute()
{
await _reports.GenerateDailyAsync();
}
}// Program.cs
// ── PostgreSQL ────────────────────────────────────────────────────────────────
builder.Services.AddPgSqlScheduler(
connectionString: builder.Configuration.GetConnectionString("Postgres")!,
schema: "quartz",
configureOptions: options =>
{
options.SystemUserIdentifier = "scheduler";
options.RetentionDays = 30;
options.CleanupCronExpression = "0 0 2 * * ?"; // 02:00 AM daily
options.EnableArchive = false;
},
assemblies: typeof(DailyReportJob).Assembly
);
// ── EF Core monitoring store ──────────────────────────────────────────────────
builder.Services.AddSchedulerMonitoring<AppDbContext>();// AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Pick the method that matches your provider:
modelBuilder.UseSchedulerPostgreSql("quartz"); // SW.Scheduler.PgSql
// modelBuilder.UseSchedulerSqlServer(); // SW.Scheduler.SqlServer
// modelBuilder.UseSchedulerSqlServer("myschema");
// modelBuilder.UseSchedulerMySql(); // SW.Scheduler.MySql
}Then add and apply a migration as normal:
dotnet ef migrations add AddScheduler
dotnet ef database updateSW.Scheduler.Viewer ships a lightweight, server-rendered dashboard built with HTMX and Pico.css. It mounts at a configurable path (default /scheduler-management) and requires no JavaScript framework.
Features:
- Live dashboard — currently running jobs, recent executions, success rate
- Execution history with filtering by job group and status
- Per-execution detail view including job parameters (context) and error messages
- Auto-refreshing via HTMX partial swaps (no full page reloads)
dotnet add package SimplyWorks.Scheduler.ViewerRequires
SimplyWorks.Scheduler.EfCoreandAddSchedulerMonitoring<TDbContext>()to be registered first — the viewer reads from thejob_executionstable.
// Program.cs
// 1. Register services — call before builder.Build()
builder.Services.AddControllersWithViews(); // required if not already added
builder.Services.AddSchedulerMonitoring<AppDbContext>(); // must come first
builder.Services.AddSchedulerViewer(opts =>
{
opts.PathPrefix = "/scheduler-management"; // default — change as needed
opts.Title = "My App Scheduler"; // shown in the browser tab and header
});
var app = builder.Build();
// 2. Wire middleware and routes — call after UseRouting (implicit in WebApplication)
app.UseSchedulerViewer(); // auth guard middleware
app.MapSchedulerViewer(); // MVC routes under PathPrefix
app.MapControllers();
app.Run();The viewer has no built-in authentication — you supply the authorization logic via a delegate. This keeps the package decoupled from your auth stack (Identity, JWT, API keys, sessions, etc.).
Set AuthorizeAsync on the options. It receives the HttpContext and must return true to allow or false to respond with 401 Unauthorized.
builder.Services.AddSchedulerViewer(opts =>
{
opts.AuthorizeAsync = ctx =>
Task.FromResult(ctx.User.Identity?.IsAuthenticated == true
&& ctx.User.IsInRole("Admin"));
});builder.Services.AddSchedulerViewer(opts =>
{
opts.AuthorizeAsync = ctx =>
{
var key = ctx.Request.Headers["X-Scheduler-Key"].FirstOrDefault();
return Task.FromResult(key == configuration["Scheduler:AdminKey"]);
};
});builder.Services.AddSchedulerViewer(opts =>
{
opts.AuthorizeAsync = ctx =>
Task.FromResult(ctx.Request.Cookies["scheduler_auth"] == "my-secret-token");
});If you'd rather use [Authorize] policies from the built-in middleware, configure AuthorizeAsync to call IAuthorizationService:
builder.Services.AddSchedulerViewer(opts =>
{
opts.AuthorizeAsync = async ctx =>
{
var authService = ctx.RequestServices.GetRequiredService<IAuthorizationService>();
var result = await authService.AuthorizeAsync(ctx.User, "SchedulerAdminPolicy");
return result.Succeeded;
};
});
⚠️ Never leaveAuthorizeAsyncasnullin production. Whennullall requests are allowed — this is intentional for local development only.
| Option | Default | Description |
|---|---|---|
PathPrefix |
"/scheduler-management" |
URL path where the UI is mounted |
Title |
"Scheduler" |
Title in the browser tab and page header |
DefaultPageSize |
50 |
Number of rows shown on the History page |
AuthorizeAsync |
null (allow all) |
Async delegate returning true to allow a request |
Don't want the built-in viewer? Skip SimplyWorks.Scheduler.Viewer entirely and build your own dashboard using the two query interfaces exposed by the library.
Inject IScheduleReader for strongly-typed queries scoped to a specific job type. Requires SW.Scheduler.EfCore.
// In your own controller or Razor Page
public class MyDashboardController(IScheduleReader reader) : Controller
{
public async Task<IActionResult> Index()
{
var running = await reader.GetRunningExecutions();
var recent = await reader.GetRecentExecutions<DailyReportJob>(limit: 20);
var failed = await reader.GetFailedExecutions<DailyReportJob>(
since: DateTime.UtcNow.AddDays(-7));
// ...
}
}Inject ISchedulerViewerQuery for runtime queries without knowing job types at compile time — ideal for a generic dashboard. Registered automatically by AddSchedulerMonitoring<TDbContext>().
public class MyDashboardController(ISchedulerViewerQuery query) : Controller
{
public async Task<IActionResult> Index()
{
var running = await query.GetRunningAsync();
var recent = await query.GetRecentAsync(limit: 50);
var history = await query.GetHistoryAsync(jobGroup: null, success: false, limit: 20);
var detail = await query.GetByFireInstanceIdAsync("some-fire-id");
// ...
}
}Both interfaces work with any database provider (PostgreSQL, SQL Server, MySQL) — the implementation is in SW.Scheduler.EfCore.
| Interface | Scheduling | Parameters | Attribute support |
|---|---|---|---|
IScheduledJob |
Startup (attribute) or runtime API | None | ✅ [Schedule], [RetryConfig], [ScheduleConfig] |
IScheduledJob<TParam> |
Runtime API only | ✅ Per-schedule | ❌ (runtime-only) |
[Schedule("0 0 2 * * ?", Description = "Cleanup at 2 AM")]
public class CleanupJob : IScheduledJob { ... }The trigger can be overridden at runtime via IScheduleRepository.Schedule<TJob>(cronExpression).
[RetryConfig(MaxRetries = 5, RetryAfterMinutes = 10)]
public class CleanupJob : IScheduledJob { ... }On failure the job catches the exception, increments a counter in the data map, and schedules a one-time trigger at now + RetryAfterMinutes. Quartz never sees the failure.
[ScheduleConfig(AllowConcurrentExecution = false, MisfireInstructions = MisfireInstructions.Skip)]
public class CleanupJob : IScheduledJob { ... }Passed to AddScheduler(options => ...) or any provider's configureOptions parameter.
| Option | Default | Description |
|---|---|---|
SystemUserIdentifier |
"scheduled-job" |
Identity name set on RequestContext during execution |
RetentionDays |
30 |
Days to keep JobExecution rows before the cleanup job deletes them |
CleanupCronExpression |
"0 0 2 * * ?" |
When the cleanup job runs (daily at 2 AM by default) |
EnableArchive |
false |
Upload execution JSON to ICloudFilesService after each run |
CloudFilesPrefix |
"" |
Key prefix for archived files, e.g. "my-app/" |
Inject IScheduleRepository anywhere to manage schedules dynamically.
// Override the attribute-defined schedule at runtime
await _scheduler.Schedule<DailyReportJob>("0 0 9 * * ?");
// Reschedule
await _scheduler.RescheduleJob<DailyReportJob>("0 0 10 * * ?");
// Pause / Resume
await _scheduler.PauseJob<DailyReportJob>();
await _scheduler.ResumeJob<DailyReportJob>();
// Remove trigger (job stays registered)
await _scheduler.UnscheduleJob<DailyReportJob>();public record NotifyParams(int CustomerId, string Template);
public class NotifyCustomerJob : IScheduledJob<NotifyParams>
{
public async Task Execute(NotifyParams p)
{
// send notification to p.CustomerId using p.Template
}
}
// Each scheduleKey is an independent Quartz job with its own data
await _scheduler.Schedule<NotifyCustomerJob, NotifyParams>(
param: new NotifyParams(42, "welcome"),
cronExpression: "0 0 9 * * ?",
scheduleKey: "notify-customer-42"
);
// Run once immediately (or at a specific time)
var key = await _scheduler.ScheduleOnce<NotifyCustomerJob, NotifyParams>(
param: new NotifyParams(42, "reminder"),
runAt: DateTime.UtcNow.AddHours(1)
);
// Reschedule / pause / resume / remove by scheduleKey
await _scheduler.RescheduleJob<NotifyCustomerJob, NotifyParams>("notify-customer-42", "0 0 10 * * ?");
await _scheduler.PauseJob<NotifyCustomerJob, NotifyParams>("notify-customer-42");
await _scheduler.ResumeJob<NotifyCustomerJob, NotifyParams>("notify-customer-42");
await _scheduler.UnscheduleJob<NotifyCustomerJob, NotifyParams>("notify-customer-42");
// Idempotent registration — creates the schedule only if it doesn't already exist.
// Returns true when newly created, false when it already existed.
// Designed for seed-style registration on app startup against a persistent store:
// safe to call on every restart across multiple nodes without creating duplicates.
bool created = await _scheduler.ScheduleIfNotExists<NotifyCustomerJob, NotifyParams>(
param: new NotifyParams(42, "welcome"),
cronExpression: "0 0 9 * * ?",
scheduleKey: "notify-customer-42"
);await _scheduler.Schedule<NotifyCustomerJob, NotifyParams>(
param: new NotifyParams(42, "welcome"),
cronExpression: "0 0 9 * * ?",
scheduleKey: "notify-42",
config: new ScheduleConfig
{
AllowConcurrentExecution = true,
MisfireInstructions = MisfireInstructions.Skip,
Retry = new RetryConfig { MaxRetries = 5, RetryAfterMinutes = 15 }
}
);Requires SW.Scheduler.EfCore + AddSchedulerMonitoring<TDbContext>().
Every job execution is automatically recorded in the job_executions table. Inject IScheduleReader to query history:
// Simple job
var last = await _reader.GetLastExecution<DailyReportJob>();
var recent = await _reader.GetRecentExecutions<DailyReportJob>(limit: 10);
var failed = await _reader.GetFailedExecutions<DailyReportJob>(since: DateTime.UtcNow.AddDays(-7));
// Parameterized job (by scheduleKey)
var last = await _reader.GetLastExecution<NotifyCustomerJob, NotifyParams>("notify-42");
var recent = await _reader.GetRecentExecutions<NotifyCustomerJob, NotifyParams>("notify-42", limit: 10);
var failed = await _reader.GetFailedExecutions<NotifyCustomerJob, NotifyParams>("notify-42");
// All currently running jobs (across cluster nodes)
var running = await _reader.GetRunningExecutions();| Field | Description |
|---|---|
Id |
Auto-increment PK |
JobName / JobGroup / JobTypeName |
Job identifier |
FireInstanceId |
Unique per execution (cluster-safe) |
StartTimeUtc / EndTimeUtc / DurationMs |
Timing |
Success / Error |
Outcome |
Node |
Environment.MachineName of the node that ran the job |
Context |
JSON-serialized ScheduledJobContext (contains JobParameter for parameterized jobs) |
Set EnableArchive = true and register ICloudFilesService (from SimplyWorks.PrimitiveTypes).
Each execution is uploaded to:
{CloudFilesPrefix}job-history/{JobGroup}/{yyyy}/{MM}/{dd}/{FireInstanceId}.json
The included SampleApplication shows all three providers wired together. Select a provider via appsettings.json:
{
"Scheduler": {
"Provider": "pgsql", // "pgsql" | "mssql" | "mysql" | (omit for in-memory)
"Schema": "quartz" // used by pgsql and mssql; ignored by mysql
},
"ConnectionStrings": {
"Scheduler": "Host=localhost;Database=sample;Username=app;Password=secret"
}
}// Program.cs
services.AddPgSqlScheduler(
connectionString: "Host=...;Database=...;",
schema: "quartz", // required
configure: o => {
o.EnableClustering = true; // optional
}
);
// AppDbContext.OnModelCreating
modelBuilder.UseSchedulerPostgreSql("quartz");// Program.cs
services.AddSqlServerScheduler(
connectionString: "Server=...;Database=...;",
configure: o => {
o.Schema = "dbo"; // optional, default: "dbo"
o.TablePrefix = "QRTZ_"; // optional, default: "QRTZ_"
}
);
// AppDbContext.OnModelCreating
modelBuilder.UseSchedulerSqlServer(); // dbo schema
modelBuilder.UseSchedulerSqlServer("scheduler"); // explicit schema// Program.cs
services.AddMySqlScheduler(
connectionString: "Server=...;Database=...;",
configure: o => {
o.TablePrefix = "QRTZ_"; // optional, default: "QRTZ_"
}
);
// AppDbContext.OnModelCreating
modelBuilder.UseSchedulerMySql();services.AddScheduler(
options => { options.RetentionDays = 7; },
assemblies: typeof(MyJob).Assembly
);SW.Scheduler uses 6-field cron syntax (powered by Quartz.NET): second minute hour dayOfMonth month dayOfWeek
| Expression | Meaning |
|---|---|
0 * * * * ? |
Every minute |
0 0 * * * ? |
Every hour |
0 0 8 * * ? |
Daily at 8 AM |
0 0 8 * * MON-FRI |
Weekdays at 8 AM |
0 */15 * * * ? |
Every 15 minutes |
0 0 0 1 * ? |
First of every month at midnight |
✅ DO
- Use
[Schedule]for fixed, predictable schedules onIScheduledJob - Use runtime API (
IScheduleRepository) for user-configurable or data-driven schedules - Use
[RetryConfig]for jobs that call external services and may fail transiently - Use
[ScheduleConfig(AllowConcurrentExecution = false)](the default) to prevent overlap - Keep job
Executemethods focused; inject services via constructor
❌ DON'T
- Don't apply
[Schedule]toIScheduledJob<TParam>— parameterized jobs are runtime-only - Don't use the same
scheduleKeyfor two different jobs - Don't reference
SW.Scheduler(or any provider package) from job-definition projects —SW.Scheduler.Sdkis enough
MIT