GM.Scheduling.EntityFramework
1.0.0
dotnet add package GM.Scheduling.EntityFramework --version 1.0.0
NuGet\Install-Package GM.Scheduling.EntityFramework -Version 1.0.0
<PackageReference Include="GM.Scheduling.EntityFramework" Version="1.0.0" />
<PackageVersion Include="GM.Scheduling.EntityFramework" Version="1.0.0" />
<PackageReference Include="GM.Scheduling.EntityFramework" />
paket add GM.Scheduling.EntityFramework --version 1.0.0
#r "nuget: GM.Scheduling.EntityFramework, 1.0.0"
#:package GM.Scheduling.EntityFramework@1.0.0
#addin nuget:?package=GM.Scheduling.EntityFramework&version=1.0.0
#tool nuget:?package=GM.Scheduling.EntityFramework&version=1.0.0
GM.Scheduling
Job scheduling for the GM.* ecosystem, wrapping Quartz.NET behind a clean abstraction — the
way GM.Caching hides Redis or GM.FileStorage hides S3. You implement IScheduledJob and drive it
with IJobScheduler; no Quartz type (ITrigger, IJobDetail, IScheduler) ever reaches your
code.
- Distributed-safe — recurring fires are guarded by
GM.DistributedLock, so a cron job runs once across every scaled-out instance instead of once per instance. - Correct DI — each execution runs in a fresh scope, so a scoped
DbContext/GM.Mediatorinjected into a job just works (no Quartz DI gotchas). - Resilient batches — a per-job
RetryPolicyretries whole-run failures with backoff, whileJobExecutionResult.Partiallets a batch skip/record one bad record and keep going. - Observable — execution history and last-run status per job.
By default it uses Quartz's in-memory store; add GM.Scheduling.EntityFramework to persist
schedules and execution history across restarts (see Persistence).
Install
dotnet add package GM.Scheduling
Register
builder.Services.AddGMDistributedLock(); // or the Redis variant — this is what makes cron run once cluster-wide
builder.Services.AddGMScheduling(s =>
{
s.Options.DefaultLockExpiry = TimeSpan.FromMinutes(10); // must exceed the slowest run
// Fluent registration with a default schedule…
s.AddJob<BillingCycleJob>("billing-cycle",
JobSchedule.Cron("0 0 2 * * ?"), // 02:00 every day (Quartz cron)
retryPolicy: RetryPolicy.Exponential(maxRetries: 3, initialDelay: TimeSpan.FromMinutes(1)));
// …or discover [ScheduledJob]-attributed jobs.
s.ScanAssemblies(typeof(Program).Assembly);
});
Implement a job — the billing/dunning shape
Handle each subscription independently so one bad record doesn't fail the batch; report the failures for observability. Throw only for a transient, whole-run failure you want retried.
public sealed class BillingCycleJob : IScheduledJob
{
private readonly SubscriptionDbContext _db; // scoped — resolved fresh per execution
private readonly IMediator _mediator; // GM.Mediator, also scoped
public BillingCycleJob(SubscriptionDbContext db, IMediator mediator) => (_db, _mediator) = (db, mediator);
public async Task<JobExecutionResult> ExecuteAsync(JobExecutionContext context, CancellationToken ct)
{
var due = await _db.Subscriptions.Where(s => s.NextBillingDue <= DateTime.UtcNow).ToListAsync(ct);
var failures = new List<string>();
var processed = 0;
foreach (var sub in due)
{
try
{
await _mediator.Send(new ChargeSubscription(sub.Id), ct); // dunning/retry is per-subscription
processed++;
}
catch (Exception ex)
{
failures.Add($"subscription {sub.Id}: {ex.Message}"); // recorded, batch continues
}
}
return failures.Count == 0
? JobExecutionResult.Success(processed)
: JobExecutionResult.Partial(processed, failures);
}
}
Control jobs at runtime
public sealed class BillingAdminController(IJobScheduler scheduler)
{
public Task RunNow() => scheduler.TriggerNowAsync("billing-cycle");
public Task Retune() => scheduler.ScheduleRecurringAsync("billing-cycle", "0 0 3 * * ?");
public Task Pause() => scheduler.PauseAsync("billing-cycle");
public Task Resume() => scheduler.ResumeAsync("billing-cycle");
public Task<JobExecutionRecord?> LastRun() => scheduler.GetLastExecutionAsync("billing-cycle");
}
TriggerNowAsync / ScheduleAsync accept a data dictionary (string values) surfaced on
JobExecutionContext.Data — e.g. to run a one-off dunning pass for a single tenant.
How it's kept correct
- Once cluster-wide — every fire tries to take a
GM.DistributedLockkeyed by job + occurrence. The instance that wins runs it; the others skip. With Quartz's in-memory store each instance fires independently, so this lock is what makes a cron single-execution. KeepDefaultLockExpiryabove the job's worst-case duration. - Fresh scope per run — the wrapper opens a DI scope and resolves your job (registered scoped) from
it, then disposes it. That's what makes
DbContextand other scoped services behave. - No overlap — a single job never overlaps itself on one instance (
DisallowConcurrentExecution, keyed per job name); different jobs still run in parallel. - Retry vs. partial — throw to retry the whole run (per
RetryPolicy, rescheduled with backoff); returnPartialto record handled per-item failures without a retry.
Cron format
JobSchedule.Cron(...) takes a Quartz cron expression (6–7 fields, seconds first):
"0 0 2 * * ?" = 02:00 daily. JobSchedule.EveryInterval(TimeSpan) and JobSchedule.Once(...) cover
the non-cron cases.
Persistence
By default schedules and history live in memory. Add GM.Scheduling.EntityFramework to persist
them — configured on the same builder, still no Quartz types in your code:
dotnet add package GM.Scheduling.EntityFramework
builder.Services.AddGMScheduling(s =>
{
s.AddJob<BillingCycleJob>("billing-cycle", JobSchedule.Cron("0 0 2 * * ?"));
// Schedules & triggers survive restarts (and coordinate across instances on a shared DB).
s.UseQuartzAdoStore(QuartzAdoProvider.PostgreSql, connectionString);
// Execution history / last-run status persist to their own DbContext.
s.UseEntityFrameworkHistory(o => o.UseNpgsql(connectionString));
});
UseQuartzAdoStore supports SQL Server, PostgreSQL, SQLite, and MySQL. The Quartz tables (QRTZ_*)
must be created from the Quartz.NET schema script for your database; the history table
(gm_job_executions) is an EF model you migrate like any other.
Failure alerts
When a job throws, the scheduler calls IJobFailureNotifier (after recording the failure) — the seam
for alerting via GM.Notifications (Herald: email / Slack / SMS). The default is a no-op; provide an
implementation and act when the failure is terminal:
public sealed class SlackJobFailureNotifier(ISlackSenderService slack) : IJobFailureNotifier
{
public Task OnJobFailedAsync(JobFailureContext ctx, CancellationToken ct) =>
ctx.RetriesExhausted // don't alert on failures that will still retry
? slack.SendAsync($"⚠️ Job '{ctx.JobName}' failed after {ctx.Attempt + 1} attempts: {ctx.ErrorMessage}", ct)
: Task.CompletedTask;
}
builder.Services.AddSingleton<IJobFailureNotifier, SlackJobFailureNotifier>();
It's resolved per execution scope (so it can use scoped services) and its own exceptions are swallowed — a broken alert never affects the job pipeline.
Roadmap
GM.Scheduling.Notifications— a ready-madeIJobFailureNotifieroverGM.Notifications, so alerting is a package add rather than a class to write.
License
MIT — see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- GM.Scheduling (>= 1.0.0)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.0)
- Quartz.Serialization.SystemTextJson (>= 3.13.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 96 | 8/31/2026 |