Shaunebu.Common.Scheduler 1.1.0

dotnet add package Shaunebu.Common.Scheduler --version 1.1.0
                    
NuGet\Install-Package Shaunebu.Common.Scheduler -Version 1.1.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="Shaunebu.Common.Scheduler" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Shaunebu.Common.Scheduler" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Shaunebu.Common.Scheduler" />
                    
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 Shaunebu.Common.Scheduler --version 1.1.0
                    
#r "nuget: Shaunebu.Common.Scheduler, 1.1.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 Shaunebu.Common.Scheduler@1.1.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=Shaunebu.Common.Scheduler&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Shaunebu.Common.Scheduler&version=1.1.0
                    
Install as a Cake Tool

⏰ Shaunebu.Common.Scheduler

Platform License Production Ready Performance Easy

NuGet Version

NET Support NET MAUI Enterprise

Scheduler Distributed Retries Monitoring

Support

Shaunebu.Common.Scheduler is an in-process scheduler for .NET applications. It supports delegate jobs, DI-resolved typed jobs, one-time and recurring triggers, retry policies, Microsoft.Extensions.DependencyInjection integration, optional Generic Host lifecycle integration, Microsoft.Extensions.Logging, and lightweight plugin callbacks.

⚠️ Important: This package is designed as a single-process scheduler runtime. It does not provide durable executable-job recovery, cluster coordination, leader election, exactly-once execution across replicas, or built-in SQL/Redis/Prometheus/Slack/Application Insights providers.

✨ Key Features

  • ⏱️ In-process scheduling for net9.0 and net10.0.
  • 📅 One-time, interval, daily, business-day, cron, event, and chained triggers.
  • 🆔 Generated job IDs for ordinary IScheduler calls.
  • 🔖 Caller-supplied stable job IDs through IStableJobScheduler.
  • 🔧 Delegate jobs and DI-resolved typed jobs through IJob and IScopedJobScheduler.
  • 🔁 Retry policies with fixed, linear, and exponential backoff.
  • 🧪 Optional retry exception classification with RetryPolicy.ShouldRetryException.
  • 🔄 Manual lifecycle through StartAsync and StopAsync.
  • 🏠 Optional Generic Host lifecycle integration through AddShaunebuSchedulerHostedService().
  • 🕐 Scheduler time control through SchedulerOptions.TimeProvider.
  • 📊 Microsoft.Extensions.Logging support and opt-in audit/metrics plugins.
  • 🛡️ Optional MaxScheduledJobs and MaxConcurrentJobs guardrails.

📦 Installation

dotnet add package Shaunebu.Common.Scheduler

🚀 Quick Start

using Shaunebu.Common.Scheduler.Abstractions;
using Shaunebu.Common.Scheduler.Extensions;
using Shaunebu.Common.Scheduler.Triggers;

builder.Services.AddShaunebuScheduler(options =>
{
    options.MaxConcurrentJobs = 4;
    options.MaxScheduledJobs = 1_000;
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});

var app = builder.Build();

var scheduler = app.Services.GetRequiredService<IScheduler>();
scheduler.ScheduleJob(
    "Heartbeat",
    cancellationToken =>
    {
        Console.WriteLine("Heartbeat");
        return Task.CompletedTask;
    },
    new IntervalTrigger(TimeSpan.FromMinutes(5)));

await scheduler.StartAsync();
await app.RunAsync();
await scheduler.StopAsync();

AddShaunebuScheduler(...) registers the scheduler and related services, but it does not start the scheduler. Start and stop it yourself, or add the hosted-service integration shown below.

🧩 Dependency Injection

using Shaunebu.Common.Scheduler.Abstractions;
using Shaunebu.Common.Scheduler.Extensions;
using Shaunebu.Common.Scheduler.Models;

services.AddShaunebuScheduler(options =>
{
    options.MaxConcurrentJobs = 5;
    options.MaxScheduledJobs = null;
    options.TimeProvider = TimeProvider.System;
    options.LogJobExecutions = true;
});

services.AddSchedulerPlugins(options =>
{
    options.EnableAuditPlugin = true;
    options.EnableMetricsPlugin = true;
});

Under the standard DI registration, SchedulerService, IScheduler, and IStableJobScheduler resolve to the same singleton scheduler runtime. IScopedJobScheduler is a facade over that runtime for typed jobs.

Default infrastructure is in-memory and process-local. The package also preserves compatibility extension methods such as WithSqlPersistence(...), WithRedisDistributedLocking(...), and WithEmailAlerts(...), but those methods do not currently register concrete SQL, Redis, or email providers.

🔄 Manual Lifecycle

using Microsoft.Extensions.Logging.Abstractions;
using Shaunebu.Common.Scheduler.Models;
using Shaunebu.Common.Scheduler.Providers;
using Shaunebu.Common.Scheduler.Services;

using var scheduler = new SchedulerService(
    new SchedulerOptions(),
    NullLogger<SchedulerService>.Instance,
    new DefaultJobFactory(NullLoggerFactory.Instance));

await scheduler.StartAsync();
await scheduler.StopAsync();

Call StopAsync during application shutdown so active executions can observe cancellation and the scheduler can wait up to SchedulerOptions.ShutdownTimeout. Dispose the scheduler after stopping when you create it manually.

🏠 Generic Host Integration

using Shaunebu.Common.Scheduler.Extensions;

builder.Services.AddShaunebuScheduler(options =>
{
    options.MaxConcurrentJobs = 4;
});

builder.Services.AddShaunebuSchedulerHostedService();

AddShaunebuSchedulerHostedService() is opt-in. It starts the existing scheduler singleton when the host starts and stops it when the host stops. Use either manual lifecycle or hosted lifecycle as your primary application pattern.

📅 Scheduling Jobs

using Shaunebu.Common.Scheduler.Triggers;

var jobId = scheduler.ScheduleJob(
    "Refresh cache",
    async cancellationToken => await RefreshCacheAsync(cancellationToken),
    new IntervalTrigger(TimeSpan.FromMinutes(10)));

var state = new ReportState(42);
var statefulJobId = scheduler.ScheduleJob(
    "Build report",
    state,
    async (reportState, cancellationToken) =>
    {
        await BuildReportAsync(reportState!.ReportId, cancellationToken);
    },
    new OneTimeTrigger(DateTimeOffset.UtcNow.AddHours(1)));

The returned job ID is the scheduler identity. The job name is display metadata and is not required to be unique.

🆔 Stable Job IDs

using Shaunebu.Common.Scheduler.Abstractions;
using Shaunebu.Common.Scheduler.Triggers;

var stableScheduler = serviceProvider.GetRequiredService<IStableJobScheduler>();

stableScheduler.ScheduleJob(
    "nightly-cleanup",
    "Nightly cleanup",
    async cancellationToken => await CleanupAsync(cancellationToken),
    new CronTrigger("0 2 * * *", TimeZoneInfo.Utc));

Ordinary IScheduler.ScheduleJob(...) calls generate IDs in the existing {jobName}_{guid:N} shape. Use IStableJobScheduler when the caller needs a known ID for later UnscheduleJob, IsJobScheduled, GetScheduledJobs, or TriggerJobAsync calls.

Stable job IDs must not be null, empty, or whitespace. Duplicate stable IDs are rejected with InvalidOperationException; existing jobs are not replaced.

🔧 Scoped / Typed Jobs

using Shaunebu.Common.Scheduler.Abstractions;
using Shaunebu.Common.Scheduler.Triggers;

services.AddScoped<InvoiceDbContext>();
services.AddTransient<SendInvoiceJob>();

var scopedScheduler = serviceProvider.GetRequiredService<IScopedJobScheduler>();
scopedScheduler.ScheduleJob<SendInvoiceJob>(
    "Send invoices",
    new IntervalTrigger(TimeSpan.FromHours(1)));

public sealed class SendInvoiceJob : IJob
{
    private readonly InvoiceDbContext _dbContext;

    public SendInvoiceJob(InvoiceDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task ExecuteAsync(CancellationToken cancellationToken)
    {
        return _dbContext.SendPendingInvoicesAsync(cancellationToken);
    }
}

Each typed job execution attempt creates a fresh async DI scope, resolves the job type from that scope, executes it, and disposes the scope. Retry attempts get fresh scopes too. This is useful for scoped dependencies, but it is still in-process execution and does not imply exactly-once delivery.

⏱️ Triggers

Trigger Purpose
OneTimeTrigger Executes once at a configured instant.
IntervalTrigger Recurs on a fixed interval.
DailyTrigger Runs daily at a configured time of day.
BusinessDayTrigger Runs on configured business days and skips holidays supplied by an IHolidayProvider.
CronTrigger Uses NCrontab cron parsing for recurring schedules.
EventTrigger Becomes due after an event bus notification.
ChainedTrigger Becomes due after another scheduled job completes.
new OneTimeTrigger(DateTimeOffset.UtcNow.AddMinutes(30));
new IntervalTrigger(TimeSpan.FromMinutes(5));
new DailyTrigger(new TimeSpan(9, 0, 0), TimeZoneInfo.Utc);
new BusinessDayTrigger(new TimeSpan(9, 0, 0), TimeZoneInfo.Utc);
new CronTrigger("*/15 * * * *", TimeZoneInfo.Utc);
new EventTrigger("data-ready", eventBus);
new ChainedTrigger(previousJobId, scheduler);

Convenience extensions are also available:

5.EveryMinutes();
"*/15 * * * *".Cron(TimeZoneInfo.Utc);
new TimeSpan(9, 0, 0).DailyAt(TimeZoneInfo.Utc);
DateTimeOffset.UtcNow.AddHours(1).RunOnceAt();
TimeSpan.FromMinutes(10).RunOnceIn();

Cron expressions are parsed by NCrontab. Five-field expressions omit seconds; six-field expressions include seconds. Invalid expressions throw ArgumentException. Quartz-only tokens such as ?, L, W, and # are not supported by NCrontab cron parsing.

Daily, business-day, cron, and time-zone-aware triggers use an explicit TimeZoneInfo when supplied and fall back to TimeZoneInfo.Local when omitted. Invalid local times during spring-forward DST transitions are skipped to the next valid recurrence. Ambiguous fall-back local times use the earlier occurrence/offset.

🔁 Retry Policies

using Shaunebu.Common.Scheduler.Enums;
using Shaunebu.Common.Scheduler.Models;

var retryPolicy = new RetryPolicy
{
    MaxRetries = 3,
    InitialDelay = TimeSpan.FromSeconds(1),
    BackoffStrategy = RetryBackoffStrategy.Exponential,
    MaxDelaySeconds = 300,
    ShouldRetryException = exception => exception is TimeoutException
};

scheduler.ScheduleJob(
    "Call external service",
    async cancellationToken => await CallExternalServiceAsync(cancellationToken),
    new IntervalTrigger(TimeSpan.FromMinutes(5)),
    retryPolicy);
Setting Default Behavior
MaxRetries 3 Retries after the initial attempt.
Initial attempt 1 Always attempted once when the job executes.
Maximum total attempts 4 Initial attempt plus up to three retries when MaxRetries = 3.
BackoffStrategy Exponential Fixed, linear, and exponential strategies are supported.

MaxRetries = 3 means the initial attempt plus up to three retries. Fixed, linear, and exponential backoff are supported. When ShouldRetryException is null, ordinary exceptions are retryable. When it returns false, retry stops immediately. Cancellation is treated as cancellation and is not retried.

🕐 TimeProvider / Testing

services.AddShaunebuScheduler(options =>
{
    options.TimeProvider = TimeProvider.System;
});

The configured TimeProvider is used for scheduler polling waits, retry waits, shutdown timeout waits, scheduler-owned timestamps, and default in-memory repository metadata. TimeProvider.System is the default.

Tests can supply their own TimeProvider implementation through SchedulerOptions.TimeProvider; this package does not ship a public manual-time test helper.

📊 Plugins / Observability

services.AddSchedulerPlugins(options =>
{
    options.EnableAuditPlugin = true;
    options.EnableMetricsPlugin = true;
    options.CustomPlugins.Add(typeof(MySchedulerPlugin));
});

Plugins implement ISchedulerPlugin and receive scheduler starting/stopping, job scheduled/unscheduled, job executing, and job executed callbacks. Plugin failures are logged and isolated from scheduler execution.

MetricsPlugin keeps in-memory snapshots for total executions, successful executions, failed executions, total duration, max duration, and last execution per job. It does not export Prometheus or OpenTelemetry metrics.

Logging uses Microsoft.Extensions.Logging and works with any compatible ILogger provider configured by the host. SchedulerOptions.LogJobExecutions = false suppresses routine job start/success logs while keeping retry, failure, cancellation, lifecycle, and infrastructure diagnostics. Logging EventId values are internal implementation details, not a documented public contract.

IAlertProvider is called on final job failure after retry exhaustion. The default DI registration uses ConsoleAlertProvider; manual construction without an alert provider uses NullAlertProvider. Alert provider failures are logged and isolated.

🛡️ Operational Guardrails

Option Default Purpose
MaxConcurrentJobs 5 Maximum simultaneous executions across different jobs.
MaxScheduledJobs Unlimited Optional maximum number of registered jobs.
ShutdownTimeout 30 seconds How long shutdown waits for active executions after cancellation is requested.
JobExecutionTimeout 30 minutes Validated option; currently not enforced as a per-job timeout by the scheduler runtime.
ContinueOnFailure true Failed recurring jobs remain scheduled after final failure.
LogJobExecutions true Emits routine job start and successful completion logs.
TimeProvider TimeProvider.System Scheduler clock for polling, waits, timestamps, and default repository metadata.

MaxScheduledJobs defaults to null, preserving unlimited scheduling. When configured, scheduling beyond the limit throws InvalidOperationException; jobs are not evicted. Negative timeouts and non-positive configured limits are rejected during scheduler construction.

⚙️ Scheduling Semantics

Scenario Behavior
Future one-time job Runs once when due.
Exact-now one-time job Eligible on the next scheduler evaluation.
Past one-time job Does not auto-run; remains registered with NextRun = null.
Completed or failed one-time job Remains visible with NextRun = null until explicitly unscheduled.
Missed recurring occurrence Skipped; no catch-up queue or burst of historical runs.
Same-job overlap Prevented.
Different jobs May run concurrently, subject to MaxConcurrentJobs.
Skipped overlapping occurrence Not queued.
Manual trigger Uses the same execution pipeline as scheduled execution.
Future recurring occurrence after manual trigger Not consumed or advanced by the manual trigger.

TriggerJobAsync(jobId) uses the same execution pipeline as scheduled execution, including retry, history, plugin callbacks, alerts, cancellation, and same-job overlap checks.

🚀 Performance Notes

Batch 8 benchmark baselines were collected locally on Windows 11 with .NET SDK 10.0.302 and BenchmarkDotNet 0.15.8.

Scheduled jobs Due distribution Scan mean
10 0-50% 48.11-51.98 ns
100 0-50% 170.20-172.27 ns
1,000 0-50% 1.381-1.391 us
10,000 0-50% 30.491-34.035 us

Additional measured baselines:

Operation Mean
In-memory repository snapshot at 10,000 jobs 58.588 us, 160.216 KB allocated
History query at 10,000 records 192.846 us, 220.624 KB allocated
Schedule generated ID 5.389 us
Schedule stable ID 4.604 us
Unschedule job 695.4 ns
Concurrent manual dispatch of 1,000 no-op jobs 2.326 ms, 1,626.42 KB allocated

These are local baselines, not service-level guarantees. The scheduler still uses a one-second polling architecture and an O(n) scan of scheduled jobs.

⚠️ Current Limitations

  • In-process scheduler only; no cluster-safe scheduling or leader election.
  • No exactly-once execution across replicas.
  • In-memory defaults do not survive process restart.
  • Repository abstractions store job definitions/history, but the package does not reconstruct executable delegates after restart.
  • No durable executable-job recovery, trigger serialization contract, or crash-safe replay.
  • No missed-run replay or catch-up queue.
  • No public overlap policy beyond current same-job non-overlap behavior.
  • Completed one-shot jobs are retained until unscheduled.
  • No built-in SQL, Redis, Slack, Prometheus, OpenTelemetry, or Application Insights provider.
  • Polling architecture remains in place for 1.1.0.

📦 Package / Framework Support

Item Status
Target frameworks net9.0, net10.0
Current package version 1.0.0 until the release/versioning task changes it
XML documentation Enabled
Symbol package Enabled as .snupkg
Package README Included
Package icon Not fabricated by this repository
License metadata Not fabricated by this repository
Repository URL / Source Link Not added for this private package
Product Compatible and additional computed target framework versions.
.NET net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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 Shaunebu.Common.Scheduler:

Package Downloads
Shaunebu.Bussiness.ReportGenerator.Scheduler

Cron-based scheduling module for Shaunebu ReportGenerator. Automate report generation with tenant awareness.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 99 9/3/2026
1.0.0 400 11/11/2025