SingletonJob 1.0.0
dotnet add package SingletonJob --version 1.0.0
NuGet\Install-Package SingletonJob -Version 1.0.0
<PackageReference Include="SingletonJob" Version="1.0.0" />
<PackageVersion Include="SingletonJob" Version="1.0.0" />
<PackageReference Include="SingletonJob" />
paket add SingletonJob --version 1.0.0
#r "nuget: SingletonJob, 1.0.0"
#:package SingletonJob@1.0.0
#addin nuget:?package=SingletonJob&version=1.0.0
#tool nuget:?package=SingletonJob&version=1.0.0
SingletonJob
Lightweight Redis-backed singleton background jobs for multi-instance .NET deployments. High-frequency, drop-on-overlap, no persistence overhead. A focused alternative to Hangfire for the case where you just want exactly one pod to run a global periodic job.
Why this exists
Hangfire is great for durable, retryable, observable background work. It is a poor fit when:
- The job is high frequency (every second, every 500 ms). Hangfire's job storage becomes a bottleneck. In fact Hangfire does not support jobs faster than 1 second because of limitation of cron.
- You don't want persistence, retries, or a dashboard.
- You need drop-on-overlap semantics. If the previous tick is still running, skip the next one.
- You need periodic scheduling, not just cron; and you want more control of how jobs run.
- You need exactly-one-instance execution across pods, and round-robin distribution is really not important. Distributing computation is an entire different problem.
- You need the library to be AOT compatible, which as far as I know, Hangfire is not.
| SingletonJob | Hangfire | |
|---|---|---|
| Storage | Redis lock key (~50 B) | SQL/Redis with state, history, retries |
| High-frequency (≤1 s) jobs | first-class | discouraged |
| Drop-on-overlap | yes (SingletonFixedRateJob) |
no, overlapping runs queue up |
| Run-then-wait periodic | yes (SingletonIntervalJob) |
no, only cron |
| Cron schedules | yes (SingletonCronJob) |
yes |
| Single-instance leader election | yes | no, round-robin |
| Dashboard, retries, history | no | yes |
| Dependencies | StackExchange.Redis, Cronos | many |
| AOT compatibility | yes | no |
Install
dotnet add package SingletonJob
Targets net8.0 and net10.0 (If you use net9.0 then it's the same as net8.0).
Quickstart
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SingletonJob;
using StackExchange.Redis;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
// Source-generated, AOT-safe: registers every SingletonBackgroundJob subclass at compile time.
builder.Services.AddSingletonJobs(builder.Configuration);
await builder.Build().RunAsync();
AddSingletonJobsis emitted at compile time by the bundled Roslyn source generator. There is no reflection in the registration path, so the library is fully trimming- and NativeAOT-safe.First build required. Until the generator runs at least once, your IDE will red-squiggle the call with
CS1061: 'IServiceCollection' does not contain a definition for 'AddSingletonJobs'. Rundotnet buildonce and the symbol resolves. See docs/troubleshooting.md if it still doesn't.
appsettings.json:
{
"ConnectionStrings": { "Redis": "localhost:6379" },
"SingletonJob": {
"ProjectName": "myapp",
"HeartbeatInterval": "00:00:03",
"LockExpiry": "00:00:10"
}
}
Three job shapes
// 1) Run, wait, run. "At least N seconds between runs."
public sealed class HeartbeatJob(IConnectionMultiplexer r, IOptionsFactory<SingletonJobOptions> o, ILogger<HeartbeatJob> l)
: SingletonIntervalJob(r, o, l)
{
public override string JobName => "heartbeat";
protected override TimeSpan GetJobInterval() => TimeSpan.FromSeconds(1);
protected override Task ExecuteJobAsync(CancellationToken ct) { /* ... */ return Task.CompletedTask; }
}
// 2) Fire on a fixed rate. Drop the tick if the previous run is still in flight.
public sealed class PriceTickJob(IConnectionMultiplexer r, IOptionsFactory<SingletonJobOptions> o, ILogger<PriceTickJob> l)
: SingletonFixedRateJob(r, o, l)
{
public override string JobName => "price-tick";
protected override TimeSpan GetJobInterval() => TimeSpan.FromMilliseconds(500);
protected override Task ExecuteJobAsync(CancellationToken ct) { /* ... */ return Task.CompletedTask; }
}
// 3) Cron schedule.
public sealed class DailyReportJob(IConnectionMultiplexer r, IOptionsFactory<SingletonJobOptions> o, ILogger<DailyReportJob> l)
: SingletonCronJob(r, o, l)
{
private static readonly CronExpression Expr = CronExpression.Parse("0 3 * * *");
public override string JobName => "daily-report";
protected override CronExpression GetCronExpression() => Expr;
// optional: protected override TimeZoneInfo TimeZone => TimeZoneInfo.FindSystemTimeZoneById("Asia/Singapore");
// or if you prefer local time: protected override TimeZoneInfo TimeZone => TimeZoneInfo.Local;
protected override Task ExecuteJobAsync(CancellationToken ct) { /* ... */ return Task.CompletedTask; }
}
That's it. Deploy N replicas. Exactly one runs the job.
How it works
- Each replica derives
_lockKey = "{ProjectName}:{JobName}:lock"and a unique_nodeId. - Every
HeartbeatInterval(default 3 s), each replica issues a RedisSET key value NX PX <LockExpiry>. The first one wins and becomes leader. - The leader renews the TTL with an atomic Lua script (
GET == nodeId ? PEXPIRE : 0) so only the holder can extend it. - The job loop checks
IsLeadereach iteration and only runs work if true. - On graceful shutdown the leader runs an atomic release Lua script (
GET == nodeId ? DEL : 0). This enables fast failover: the next replica acquires the lock withinHeartbeatIntervalinstead of waitingLockExpiryfor it to expire. - On hard kill (SIGKILL, OOM), the lock simply expires after
LockExpiry.
HeartbeatInterval ──▶ how often to renew (default 3s)
LockExpiry ──▶ TTL on the Redis key (default 10s)
HeartbeatInterval must be strictly less than LockExpiry. Recommend LockExpiry >= 3 * HeartbeatInterval so a single dropped network call doesn't cost leadership.
Logging levels
| Event | Level |
|---|---|
| Service start, leader transitions, release | Information |
| Per-iteration start/end + duration | Debug |
Iteration close to LockExpiry (≥80%) |
Warning |
| Job exception | Error |
Per-iteration noise is at Debug on purpose. High-frequency jobs would otherwise flood Information logs.
Inside a job, log via the inherited
Loggerfield — not the constructor parameter. The base class already stores the logger in aprotected ILogger Logger. Forwardingloggertobase(...)and referencing it from your primary-constructor body creates a second backing field for the same value, which trips compiler warning CS9124. UseLogger.LogInformation(...)(or a[LoggerMessage]static partial that takesILogger, passedLogger) instead.
Configuration
| Option | Default | Description |
|---|---|---|
ProjectName |
default |
Lock key prefix. Pick a unique value per deployment. |
HeartbeatInterval |
00:00:03 |
How often to attempt acquire/renew. |
LockExpiry |
00:00:10 |
TTL applied to the Redis lock key. |
NodeId |
null |
Override identifier. Falls back to env POD_NAME, then MachineName. |
MaxBackoffDelay |
00:00:30 |
Ceiling on the exponential backoff delay between Redis error retries. |
Validation runs on StartAsync; bad config throws. See docs/configuration.md for per-job overrides.
Documentation
| docs/getting-started.md | Install + first three jobs |
| docs/configuration.md | Every option, per-job overrides |
| docs/architecture.md | How leader election works end-to-end |
| docs/aot.md | NativeAOT + trimming, source generator details |
| docs/deployment-kubernetes.md | Pod manifest, SIGTERM, sizing |
| docs/deployment-redis.md | Standalone, Sentinel, Cluster, Memurai |
| docs/troubleshooting.md | Common pitfalls and how to debug them |
| CHANGELOG.md | Release notes per version |
Try it locally
See samples/: a worker template with all three job types, a docker-compose.yml that spins up one Redis and three workers, and a run-3-instances.ps1 for Windows local dev.
cd samples
docker compose up --build --scale worker=3
Roadmap
- Built-in
IHealthCheckso Kubernetes readiness probes can detect a wedged election loop. - Metrics via
System.Diagnostics.Metrics(counters for ticks, dropped ticks, leadership flips, durations). ActivitySourcetracing per iteration for distributed tracing.- Configurable cancellation on lost leadership (today: started iterations always run to completion).
- SQL Server / PostgreSQL backends. (Not on near roadmap. Redis remains the supported backend.)
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. 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. |
-
net10.0
- Cronos (>= 0.8.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- StackExchange.Redis (>= 2.8.16)
-
net8.0
- Cronos (>= 0.8.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- StackExchange.Redis (>= 2.8.16)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.