DotnetCronner.Stores.EntityFrameworkCore
0.0.5
See the version list below for details.
dotnet add package DotnetCronner.Stores.EntityFrameworkCore --version 0.0.5
NuGet\Install-Package DotnetCronner.Stores.EntityFrameworkCore -Version 0.0.5
<PackageReference Include="DotnetCronner.Stores.EntityFrameworkCore" Version="0.0.5" />
<PackageVersion Include="DotnetCronner.Stores.EntityFrameworkCore" Version="0.0.5" />
<PackageReference Include="DotnetCronner.Stores.EntityFrameworkCore" />
paket add DotnetCronner.Stores.EntityFrameworkCore --version 0.0.5
#r "nuget: DotnetCronner.Stores.EntityFrameworkCore, 0.0.5"
#:package DotnetCronner.Stores.EntityFrameworkCore@0.0.5
#addin nuget:?package=DotnetCronner.Stores.EntityFrameworkCore&version=0.0.5
#tool nuget:?package=DotnetCronner.Stores.EntityFrameworkCore&version=0.0.5
DotnetCronner (by Malte)
DotnetCronner is a simple, free, open-source cron job and background task scheduler for .NET. It lets you queue and manage scheduled work with a pluggable store, so you are never locked into a particular database or ORM. Register tasks with a cron expression, run them in the background, and manage them in-process — there is no separate dashboard app to host or secure.
- Cron scheduling via a small, zero-dependency parser (5 fields, or 6 with seconds).
- Two ways to register tasks: a
[CronnerTask]attribute or fluentSched<T>(...)lambdas. - Pluggable persistence through a single
ICronnerStoreinterface (in-memory by default). - Redis and Entity Framework Core stores as separate packages; Redis also works as a second-level cache.
- Per-task priority and concurrency policy; a task never overlaps with itself by default.
- Twelve lifecycle, lock, and progress hooks (global or per schedule), plus in-process progress reporting
via
ICronnerJobContext. - Configurable DI scopes, an execution-lock keepalive for long-running jobs, and build-time cron validation.
Why DotnetCronner?
I looked through a lot of cron managers for .NET, and most were packed with features and shipped a heavy dashboard. All I wanted was a simple scheduler for my app with a ready-to-use API client to trigger, manage, and delete jobs straight from my own admin panel — so I built one that's easy to use and easy to extend. Instead of a bloated core it's a plugin design, and I'll open up more extension points over time; the current state should give you a solid start.
The other thing I kept running into: I usually had to pull in some ORM just to make the scheduler
persistent. So DotnetCronner ships two built-in stores as separate packages, on top of a small
ICronnerStore interface that takes only a few lines to implement — use whatever ORM, database, or file
store you like. Take my stores and second-level caches, or write your own; switching between them is no
headache.
This is my first time working this deeply with cron scheduling, so I started with what my own projects needed most. It isn't the finished product — and yes, it stays free.
Packages
Each package is versioned and released independently.
| Package | Version | Downloads | Purpose |
|---|---|---|---|
| DotnetCronner | Core engine, in-memory store, build-time analyzer | ||
| DotnetCronner.Abstractions | Interfaces, models, [CronnerTask] attribute |
||
| DotnetCronner.Stores.Redis | Redis store and second-level cache | ||
| DotnetCronner.Stores.EntityFrameworkCore | Entity Framework Core store |
Requirements
- .NET 10 SDK / runtime.
- Optionally StackExchange.Redis (Redis package) or Entity Framework Core (EF Core package) — pulled in transitively when you install those packages.
Install
The core package is all you need to get started:
dotnet add package DotnetCronner
NuGet\Install-Package DotnetCronner
Add a store package only if you need durable or distributed persistence:
dotnet add package DotnetCronner.Stores.Redis
dotnet add package DotnetCronner.Stores.EntityFrameworkCore
Enable it
Register the services, then configure stores and tasks after the host is built:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddDotnetCronner();
var app = builder.Build();
app.UseDotnetCronner(cronner => cronner
// .UseRedisAsStore("localhost:6379") // or .UseEntityFrameworkStore<AppDbContext>()
.Sched<ReportJobs>(
x => x.SendAsync(x.HasParam<IReportService>(), x.HasParam<CancellationToken>()),
o => o.WithCron("0 * * * *").WithPrio(CronnerTaskPriority.High)));
app.Run();
You can also configure everything up front in AddDotnetCronner(cronner => ...); the same builder is
available in both places.
Configuration
Configure via AddDotnetCronner(c => c.Configure(o => ...)) or app.UseDotnetCronner(c => c.Configure(...)).
| Option | Default | Description |
|---|---|---|
TimeZone |
UTC | Time zone cron expressions are evaluated in |
PollingInterval |
1s | How often the store is polled for due tasks |
MaxConcurrentTasks |
processor count | Global concurrent execution limit |
LockTtl |
1 min | Lock validity window. A running task renews its claim every LockTtl/2, so long jobs keep their lock; a task is treated as stalled and reclaimable only after a worker stops renewing for longer than this (e.g. a crash) |
KeepAliveInterval |
null (= LockTtl/2) |
Pins how often the lock renews and OnKeepAlive fires, independently of LockTtl (also via WithKeepAliveInterval(...)). Keep it below LockTtl |
OneOffRetentionCount |
0 (keep all) | Keep only the newest N finished one-off (enqueued) instances per definition (also via WithOneOffRetention(N)) |
ExecutionHistoryRetentionCount |
0 (off) | Record per-run execution history, keeping the newest N runs per task (also via WithExecutionHistory(N)). Read with ICronnerClient.GetExecutionsAsync(...) |
HookScope |
Shared |
Default scope for terminal hooks: Shared (the job's scope) or Isolated (own fresh scope). Override per hook via AddHook/WithHook |
OnInvalidSchedule |
MarkFailed |
A cron that parses but never fires: MarkFailed (mark that task Failed, keep the rest) or Throw (fail host startup) |
DefaultMaxRetries / RetryDelay |
0 / 0 | Automatic retry on failure |
ScanEntryAssembly |
true | Scan the entry assembly for [CronnerTask] methods |
Usage
Attribute tasks
public class ReportJobs
{
[CronnerTask(cronstring: "*/5 * * * *", Priority = CronnerTaskPriority.Normal)]
public Task SendAsync(IReportService reports, CancellationToken ct) => reports.SendAsync(ct);
}
If id is omitted it is derived from the fully qualified Type.Method name. Registering two tasks with
the same id throws. Attribute parameters are resolved from dependency injection, with CancellationToken
bound to the task's token.
By default [CronnerTask] methods in the entry assembly are discovered automatically. To control exactly
what gets registered:
app.UseDotnetCronner(c => c
.DisableAutoDiscovery() // stop scanning the entry assembly
.AutoDiscoverFromAssembly(typeof(ReportJobs)) // scan the assembly containing this type
.AutoDiscoverFromType(typeof(IScheduledJob))); // only types implementing this interface / base class
AutoDiscoverFromAssembly also accepts Assembly values; AutoDiscoverFromType filters types across
every scanned assembly.
Lambda tasks and HasParam
Inside a Sched lambda, x.HasParam<T>() declares a parameter that is supplied at run time: a
CancellationToken is bound to the task's token, anything else is resolved from the scoped service
provider. Ordinary values ("world", 42, …) are captured as literals.
app.UseDotnetCronner(c => c.Sched<ReportJobs>(
x => x.SendAsync(x.HasParam<IReportService>(), x.HasParam<CancellationToken>()),
o => o.WithCron("0 * * * *")));
A second HasParam overload takes a factory so you can resolve the value yourself from the execution
scope's provider — useful for pulling a value off a scoped accessor:
app.UseDotnetCronner(c => c.Sched<TenantJobs>(
x => x.Run(x.HasParam<Tenant>(sp => sp.GetRequiredService<ITenantAccessor>().Current)),
o => o.WithCron("*/10 * * * *")));
Stores
// Redis backing store (package: DotnetCronner.Stores.Redis)
app.UseDotnetCronner(c => c.UseRedisAsStore("localhost:6379"));
// Secured Redis — auth + TLS travel in the connection string…
app.UseDotnetCronner(c => c.UseRedisAsStore("myhost:6380,user=cronner,password=SECRET,ssl=true,sslHost=myhost"));
// …or via ConfigurationOptions for full control (SslProtocols, cert callbacks, …):
app.UseDotnetCronner(c => c.UseRedisAsStore(o => o.ConfigurationOptions = new ConfigurationOptions
{
EndPoints = { "myhost:6380" }, User = "cronner", Password = "SECRET", Ssl = true, SslHost = "myhost",
}));
// …or bring your own multiplexer: o.ConnectionMultiplexerFactory = sp => existingMultiplexer;
// …or configure entirely from DI — e.g. pull the (secured) string from IConfiguration:
app.UseDotnetCronner(c => c.UseRedisAsStore((sp, o) =>
o.Configuration = sp.GetRequiredService<IConfiguration>().GetConnectionString("Redis")));
// EF Core backing store (package: DotnetCronner.Stores.EntityFrameworkCore)
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlServer(cs));
app.UseDotnetCronner(c => c.UseEntityFrameworkStore<AppDbContext>());
// Custom store — implement ICronnerStore
app.UseDotnetCronner(c => c.UseStore<MyStore>());
Bring your own persistence (Dapper, NHibernate, a UnitOfWork, …) by implementing ICronnerStore —
see writing a custom store. Abstractions ships a ready-made CronnerJobEntity
(a mutable class with virtual properties and ToDomain/From/Apply helpers) you can map or subclass
instead of hand-rolling one.
AppDbContext implements ICronnerDbContext and calls modelBuilder.ApplyCronnerModel() in
OnModelCreating. Configuring more than one store throws. For creating the CronnerJobs table with EF
Core migrations (for both a DbContext in your main project and one in a separate class library), see
the migrations guide.
Second-level cache
app.UseDotnetCronner(c => c
.UseEntityFrameworkStore<AppDbContext>()
.UseSecondLevelCache(cache => cache.UseRedisCacheProvider("localhost:6379")));
Concurrency policy
Set per task with .WithConcurrency(...) or the attribute's Concurrency property
([CronnerTask(cronstring: "*/5 * * * *", Concurrency = CronnerConcurrencyMode.Queue)]):
DropAndForget(default) — skip an occurrence that fires while a previous run is still executing.Queue— run the missed occurrence immediately after the current one finishes (never overlapping).Concurrent— allow overlapping runs.
Dependency injection and scopes
By default each run executes in a fresh scope created from your application's service provider, so scoped
services behave exactly as they do in a request. To run against an isolated container instead — to avoid
sharing scoped services with web requests, or to give jobs their own dependencies — use
WithDedicatedDI. You supply the service collection, and the discovered job classes are registered
into it with the lifetime you choose (Scoped by default; Singleton, Transient, …):
app.UseDotnetCronner(cronner => cronner
.WithDedicatedDI(services =>
{
services.AddSingleton<IEmailClient, SmtpEmailClient>();
services.AddDbContextFactory<JobsDbContext>(o => o.UseNpgsql(cs));
}, jobLifetime: ServiceLifetime.Singleton)
.Sched<MailJobs>(x => x.SendDigestAsync(x.HasParam<IEmailClient>(), x.HasParam<CancellationToken>()), "0 8 * * *"));
A fresh scope is still created per run. DI-registered ICronnerTaskHooks are resolved from the same
provider, so register them in the dedicated collection when using dedicated services.
Lifecycle and lock hooks
Hook into task execution for logging, metrics, notifications, or custom error handling. A hook that throws is logged and ignored, so it never breaks a task. There are twelve events:
| Event | Fires |
|---|---|
OnStart |
before the task method runs |
OnSuccess |
after a successful run (ctx.Duration is set) |
OnFail |
after the task throws (ctx.Exception is set) |
OnCancel |
after the task is cancelled |
OnLockAcquire |
when the execution lock is claimed |
OnKeepAlive |
on each lock renewal (keepalive) while the task runs |
OnLockRelease |
when the lock is released |
OnLockLost |
when the lock is lost mid-run and the run is cancelled |
OnTotalProgressChange |
when a task reports total progress (ctx.TotalProgress) |
OnProgressScopeOpened |
when a task opens a progress scope (ctx.ProgressScope) |
OnScopeProgress |
when a task reports progress to a scope |
OnProgressScopeClosed |
when a task closes a progress scope |
Global, as a delegate:
app.UseDotnetCronner(cronner => cronner
.OnStart(ctx => { logger.LogInformation("Starting {Id}", ctx.Job.Id); return Task.CompletedTask; })
.OnSuccess(ctx => { metrics.RecordSuccess(ctx.Job.Id, ctx.Duration); return Task.CompletedTask; })
.OnFail(ctx => alerts.NotifyAsync(ctx.Job.Id, ctx.Exception!))
.OnLockLost(ctx => alerts.LockLostAsync(ctx.Job.Id))
.Sched<ReportJobs>(x => x.SendAsync(x.HasParam<IReportService>(), x.HasParam<CancellationToken>()), "0 * * * *"));
Per schedule — the same On* methods (plus WithHook) on the schedule options, so a hook applies to
one task only:
cronner.Sched<ReportJobs>(x => x.SendAsync(...), o => o
.WithCron("0 * * * *")
.OnSuccess(ctx => metrics.RecordSuccess(ctx.Job.Id, ctx.Duration))
.OnLockLost(ctx => alerts.LockLostAsync(ctx.Job.Id)));
As a method call with HasParam — every On* also accepts a target type and a method-call lambda
(the same shape as Sched), resolving arguments with HasParam<T>() / HasParam<T>(sp => ...):
cronner.OnFail<AlertHandler>(h => h.Notify(h.HasParam<ISlack>()))
.Sched<ReportJobs>(x => x.SendAsync(...), o => o
.WithCron("0 * * * *")
.OnStart<AuditHandler>(h => h.RecordStartAsync(h.HasParam<IAudit>())));
Or a reusable hook implementing ICronnerTaskHook, registered globally (AddHook<T>() / AddHook(instance))
or per schedule (WithHook<T>() / WithHook(instance)), or directly in DI:
public sealed class LoggingHook(ILogger<LoggingHook> logger) : ICronnerTaskHook
{
public Task OnFailAsync(CronnerTaskContext ctx)
{
logger.LogError(ctx.Exception, "Task {Id} failed after {Duration}", ctx.Job.Id, ctx.Duration);
return Task.CompletedTask;
}
}
builder.Services.AddScoped<ICronnerTaskHook, LoggingHook>(); // or cronner.AddHook<LoggingHook>()
ICronnerTaskHook has default method implementations, so override only the events you need. By default the
terminal lifecycle hooks (OnStart/OnSuccess/OnFail/OnCancel) run in the job's execution
scope, so a hook's ctx.HasParam<T>() resolves the same scoped instances the job used — e.g. the job
writes a summary into a scoped service and the terminal hook reads it back. Lock and progress hooks
fire outside the job's execution, so each runs in its own fresh scope. Resolve scoped services through
ctx.Services or ctx.HasParam<T>(); CronnerTaskContext exposes the Job, Services, HasParam<T>(),
CancellationToken, Duration, and the Exception on failure.
Hook scope — per hook. Scope applies only to the terminal hooks (OnStart/OnSuccess/OnFail/
OnCancel), and you set it per hook on registration:
cronner.AddHook<AuditHook>(CronnerHookScope.Isolated); // this hook: own scope, no contention with the job's UoW
cronner.AddHook<LoggingHook>(); // this hook: inherits the default (Shared)
// per schedule: .Sched<Job>(..., o => o.WithHook<TxHook>(CronnerHookScope.Isolated))
A hook with no explicit scope inherits HookScope (default Shared = the job's scope; set it via
Configure to flip the default to Isolated). Shared lets a hook's ctx.HasParam<T>() resolve the
same scoped instances the job used; Isolated gives the hook its own fresh scope — use it when the job's
scope holds a single-session unit of work (one DbContext/ISession) a hook writing on the same scope
would contend with. In an isolated hook, pass job data across with the run-state bag rather than shared
scoped services. OnKeepAlive, the other lock hooks, and progress hooks always run in their own fresh
scope regardless — OnKeepAlive fires detached and concurrently with the running job, so sharing the
job's scope (and its session) would be a bug; it reads the run-state bag for job data without ever touching
the job's scope.
Run-state bag. For scope-independent data flow, the job stashes values the hooks read back:
ctx.Set(value) from the job (ICronnerJobContext), ctx.Get<T>() / ctx.TryGet<T>(out …) from a hook
(CronnerTaskContext). The bag is per run (concurrent runs never share) and is visible to OnKeepAlive
and the terminal hooks — including OnFail — regardless of hook scope. (OnStart fires before the body, so
it can't see values the job sets.) This is the clean way to build a teardown/notification summary as pure
data: the job materializes it, the hook just reads it — no need to keep the job's session alive.
Retries and
OnFail: withDefaultMaxRetries > 0,OnFailfires on each failed attempt (not once after retries are exhausted). Checkctx.WillRetry— it'struewhile attempts remain andfalseon the final failure — to alert only once per incident.
For database work inside a hook, resolve your own scoped unit-of-work that way — don't call
ICronnerStore from a hook. It has no per-hook session, and creating the scope alone opens no
connection, so hooks that don't touch a database cost nothing. OnKeepAlive in particular is fired
without blocking the lock renewal, so a slow keepalive hook can never delay a renewal or cost you the
lock — no matter how long the task runs.
Reporting progress
A running task can report progress by pulling ICronnerJobContext from DI (constructor injection or
HasParam<ICronnerJobContext>()). Report overall progress directly, and open a scope for a
subtask/category whose progress is tracked independently. Progress is a 0..1 fraction by convention
(not enforced). Each report raises the matching hook.
public sealed class ImportJob
{
public async Task Run(ICronnerJobContext ctx, CancellationToken ct)
{
ctx.Progress(0.1m); // total progress (fire-and-forget)
await using (var files = ctx.OpenProgressScope("files"))
{
files.Progress(0.5m); // subtask / category progress
await files.ProgressAsync(1.0m); // awaitable variant
} // dispose closes the scope
await ctx.ProgressAsync(1.0m);
}
}
Observe it with the progress hooks (global or per schedule) — ctx.TotalProgress on total changes, and
ctx.ProgressScope (.Id, .Category, .Value) on scope events:
cronner.OnTotalProgressChange(ctx => hub.PushAsync(ctx.Job.Id, ctx.TotalProgress))
.OnScopeProgress(ctx => hub.PushAsync(ctx.Job.Id, ctx.ProgressScope!.Category, ctx.ProgressScope!.Value));
Each progress report can also carry a custom payload — any object — delivered to the hook as
ctx.ProgressPayload for that report. It works for both total and scope progress and is the place for
per-report detail a scope's Category can't hold (a current-step name, an item id, a partial result):
ctx.Progress(0.4m, "importing orders"); // total: ctx.ProgressPayload == "importing orders"
files.Progress(0.5m, new { file = "part-3.csv" }); // scope: ctx.ProgressPayload is the anonymous object
// hook: cronner.OnTotalProgressChange(ctx => hub.PushAsync(ctx.Job.Id, ctx.TotalProgress, ctx.ProgressPayload))
Both Progress (fire-and-forget) and ProgressAsync (awaits the hooks) are available on the context and
on a scope; fire-and-forget reports are drained before the terminal OnSuccess/OnFail hook runs. (Progress
hooks can also read the per-run state bag via ctx.Get<T>(), exactly like the terminal hooks.)
One-off jobs — enqueue with a payload
Beyond recurring cron tasks, you can enqueue a single run of a registered task carrying a typed
payload. Define an enqueue-only task (a [CronnerTask] with no cron) whose payload is an ordinary
parameter — everything else still resolves from DI:
public sealed record ImportPayload(int BatchSize, string Source);
public class ImportJobs
{
[CronnerTask("import:run", Description = "One-off import")] // no cron = enqueue-only
public Task RunAsync(ImportPayload payload, IImporter importer, CancellationToken ct)
=> importer.RunAsync(payload.Source, payload.BatchSize, ct);
}
// Enqueue it now (or at a future time); returns the one-off instance id.
string id = await client.EnqueueAsync("import:run",
new ImportPayload(500, "steam"), runAt: null);
The payload is JSON-serialized as its declared type (cycle-safe and proxy-safe — pass plain DTOs, not
lazy-loading ORM entities) and delivered to the parameter whose type matches. The instance runs once
and then completes; enable retention with WithOneOffRetention(N) to keep only the newest N finished
instances per definition. CronnerJob.Kind / DefinitionId distinguish a one-off instance from its
recurring definition.
Managing tasks — ICronnerClient
There is no bundled dashboard. Inject ICronnerClient and expose management through your own, already
secured, endpoints:
// Store rows (tasks that have run); GetRegisteredTasks() lists every definition incl. never-run ones.
app.MapGet("/tasks", (ICronnerClient c, CronnerTaskState? state, int offset = 0, int limit = 50)
=> c.GetTasksAsync(state, offset, limit));
app.MapGet("/registered", (ICronnerClient c) => c.GetRegisteredTasks());
app.MapGet("/tasks/{id}", (ICronnerClient c, string id) => c.GetTaskByIdAsync(id));
app.MapPost("/tasks/{id}/run", (ICronnerClient c, string id) => c.TriggerNowAsync(id)); // run now, ignoring cron
app.MapPost("/tasks/{id}/cancel", (ICronnerClient c, string id) => c.CancelTaskAsync(id)); // cancel running or unschedule
app.MapGet("/tasks/{id}/history", (ICronnerClient c, string id) => c.GetExecutionsAsync(id, 20)); // recent runs
TriggerNowAsync is the unambiguous "run now" (ScheduleTaskAsync is an alias). CancelTaskAsync cancels
a running execution via its token, or unschedules a pending one. GetRegisteredTasks() returns the
in-memory definitions (with Description), so an admin screen can list manual/enqueue-only jobs that have
never produced a store row.
Execution history
Enable it with WithExecutionHistory(keepPerTask) (off by default). Each run records a Running entry when
it starts and finalizes it to Succeeded / Failed / Cancelled (with the error and duration) when it
ends; older entries are pruned to the per-task cap. GetExecutionsAsync(taskId, limit) returns them newest
first. A Running entry that never finalized marks a crashed or stalled run. Each entry also records the
owning scheduler instance (Owner — "which node ran this") and an optional consumer blob: call
ctx.SetExecutionData(mySummary) from the job or a hook and it is stored as JSON on the entry's Data, so
one history row can carry your own summary/log reference instead of a parallel table. The EF Core store
persists history in a new CronnerJobExecutions table (generate a migration after upgrading); the Redis
and in-memory stores need no schema step.
Execution semantics
OnFailfires per attempt, not per run. WithDefaultMaxRetries > 0each failed attempt fires it; usectx.WillRetryto act only on the final failure.- One run per task across processes is enforced by the store's execution lock: while one instance holds
a task's claim, no other instance can claim it.
DropAndForget/Queuegovern missed-occurrence catch-up within the owning worker;Concurrentis the exception — it releases the claim up front to allow parallel runs. CancelTaskAsyncis state-dependent, not both at once: a running task is signalled through its token (it stops if it honours the token); a non-running task is unscheduled.OnLockLostfires precisely when a keepalive renewal (RenewLockAsync) returnsfalse— i.e. the claim was reclaimed elsewhere — and nothing else.
Validation
Cron mistakes are caught as early as possible:
- At build time, the bundled Roslyn analyzer flags problems on
[CronnerTask]in your editor and in CI:DC0001(error): an invalid cron expression, e.g.[CronnerTask(cronstring: "not a cron")].DC0002(warning): two[CronnerTask]attributes sharing the same explicit id.
- At registration, invalid cron strings — from attributes or
Sched(...)lambdas — throw immediately at startup, naming the offending task, rather than failing silently later.
The analyzer ships inside the DotnetCronner package, so no extra reference is needed.
Limitations and roadmap
DotnetCronner is pre-1.0. Expect a few more 0.0.x releases and one or more previews before a stable
1.0.0. While on 0.x, the public API may still change between releases as it settles and more extension
points open up, so pin your versions accordingly — 1.0.0 will follow once the surface has proven itself
in real use.
Running DotnetCronner across multiple instances or nodes is not officially supported yet. The Redis and EF Core stores already include the claiming primitives this needs (per-job locks and optimistic concurrency), but coordinated multi-node operation has not been fully validated. It is on the roadmap — contributions are very welcome, so feel free to open a PR.
For now, run a single scheduler instance (other instances of your app can still use ICronnerClient to
read and trigger tasks against the shared store).
Regardless of instance count, a running task holds an execution lock that the scheduler keeps alive by
renewing it every LockTtl/2. A long-running job therefore keeps its claim for as long as it runs — it is
never mistaken for a stalled worker and re-run. A task only becomes reclaimable after its worker stops
renewing for longer than LockTtl (a crash or a very long GC pause); when that reclaim happens it is
logged as a warning. If a worker loses a lock mid-run, its own execution is cancelled so the task is never
running twice at once.
Samples
Two runnable samples live in samples/:
DotnetCronner.Sample.WebApi— a minimal quickstart: attribute + lambda tasks, execution history, and a progress job whose reports carry custom payloads, over a handful ofICronnerClientendpoints (/tasks,/tasks/{id}/history,/progress).CronTestApp— a full harness that exercises every feature (all store modes, all twelve hooks in every registration style, progress, the keepalive and lock loss, discovery and DI modes), driven entirely by a.envfile. It's a Docker Compose project (app + Redis + PostgreSQL) — requires Docker + Docker Compose: copy.env.exampleto.envanddocker compose up --build.
Changelog
Each package keeps its own changelog. See CHANGELOG.md for the index.
Contributing and releases
See CONTRIBUTING.md for building, testing, and the per-package release process.
Packages are released independently and only by a maintainer running the Release Package workflow
(Actions → Run workflow): pick the package and patch/minor/major and it computes the next version
from the last release tag, so there's nothing to look up. Merging a PR never publishes, and the NuGet push
waits on an approval gate — so a wrong version or a rogue release can't slip through. The git tag is
created by the workflow when the release actually publishes.
Contribute / Donations
If you have any ideas to improve DotnetCronner, feel free to send a pull request.
If you like my work and want to support me (or want to buy me a coffee/beer), PayPal donations are more than appreciated.
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
- DotnetCronner (>= 0.0.5)
- DotnetCronner.Abstractions (>= 0.0.5)
- Microsoft.EntityFrameworkCore (>= 10.0.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
[0.0.5] - 2026-08-18
Added
- Execution-history persistence: a new CronnerJobExecutions table (entity CronnerJobExecutionEntity, a
numeric identity key + TaskId foreign key to the jobs table with ON DELETE CASCADE + an indexed
correlation id, plus Owner and Data columns) with RecordExecutionStartedAsync (insert),
RecordExecutionFinishedAsync (targeted ExecuteUpdate matched on the correlation id), GetExecutionsAsync
and PruneExecutionsAsync.
⚠️ Migration required
- This adds a new table and mapping. After upgrading you must generate and apply a new EF Core migration
(dotnet ef migrations add AddCronnerJobExecutions then database update, or your normal migration flow).
The scheduler only writes history when WithExecutionHistory is enabled, but the table must exist first.