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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="GM.Scheduling.EntityFramework" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="GM.Scheduling.EntityFramework" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="GM.Scheduling.EntityFramework" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add GM.Scheduling.EntityFramework --version 1.0.0
                    
#r "nuget: GM.Scheduling.EntityFramework, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package GM.Scheduling.EntityFramework@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=GM.Scheduling.EntityFramework&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=GM.Scheduling.EntityFramework&version=1.0.0
                    
Install as a Cake Tool

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.Mediator injected into a job just works (no Quartz DI gotchas).
  • Resilient batches — a per-job RetryPolicy retries whole-run failures with backoff, while JobExecutionResult.Partial lets 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.DistributedLock keyed 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. Keep DefaultLockExpiry above 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 DbContext and 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. partialthrow to retry the whole run (per RetryPolicy, rescheduled with backoff); return Partial to 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-made IJobFailureNotifier over GM.Notifications, so alerting is a package add rather than a class to write.

License

MIT — see LICENSE.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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