GM.Scheduling 1.0.0

dotnet add package GM.Scheduling --version 1.0.0
                    
NuGet\Install-Package GM.Scheduling -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" 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" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="GM.Scheduling" />
                    
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 --version 1.0.0
                    
#r "nuget: GM.Scheduling, 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@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&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=GM.Scheduling&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 (1)

Showing the top 1 NuGet packages that depend on GM.Scheduling:

Package Downloads
GM.Scheduling.EntityFramework

EF Core + Quartz ADO.NET persistence for GM.Scheduling. UseQuartzAdoStore() switches Quartz to a persistent job store so schedules and triggers survive restarts and coordinate across instances; UseEntityFrameworkHistory() records job execution history / last-run status to a DbContext so observability data outlives the process. Configured on the same AddGMScheduling builder — no Quartz types leak to your app.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 116 8/31/2026