Dloizides.Jobs
1.4.1
dotnet add package Dloizides.Jobs --version 1.4.1
NuGet\Install-Package Dloizides.Jobs -Version 1.4.1
<PackageReference Include="Dloizides.Jobs" Version="1.4.1" />
<PackageVersion Include="Dloizides.Jobs" Version="1.4.1" />
<PackageReference Include="Dloizides.Jobs" />
paket add Dloizides.Jobs --version 1.4.1
#r "nuget: Dloizides.Jobs, 1.4.1"
#:package Dloizides.Jobs@1.4.1
#addin nuget:?package=Dloizides.Jobs&version=1.4.1
#tool nuget:?package=Dloizides.Jobs&version=1.4.1
Dloizides.Jobs
The fleet-wide standard for checkpointed, resumable, conflict-safe, watchdog-alarmed, UI-visible background jobs — the storage-agnostic core. One shared vehicle so no service re-invents its job runner.
Every long-running background job is single-flight, lease-recovered, checkpointed (resumes where it left off), conflict-safe, watchdog-alarmed, and visible in the UI.
Pair this with a storage package for persistence — Dloizides.Jobs.EntityFrameworkCore
provides the EF Core + relational store (jsonb checkpoint/progress, the single-flight index, and the
compare-and-set transitions).
The failure this prevents
A long ingest was reclaimed on a deploy, restarted from scratch, and the runner then error-looped on a reclaim-vs-complete race for hours — healthy pods, zero progress, days-stale with no alarm. Two gaps: progress was not durable (a reclaim meant "start over", not "resume") and transitions were brittle (a losing optimistic-concurrency write threw and killed the whole poll). This package fixes the class.
What you implement
public sealed class IngestJob : ICheckpointableJob
{
public string Name => "ingest";
// Watched hourly; the watchdog alarms if it hasn't succeeded in 2h.
public JobCadence Cadence => JobCadence.Every(TimeSpan.FromHours(1), TimeSpan.FromHours(2));
public async Task RunAsync(IJobContext ctx, CancellationToken ct)
{
// Resume from the last checkpoint, or start fresh.
var state = ctx.LoadCheckpoint<IngestState>() ?? new IngestState(Cursor: 0);
for (var cursor = state.Cursor; cursor < Total; cursor++)
{
await ProcessAsync(cursor, ct); // idempotent per checkpoint (upsert, don't blind-insert)
if (cursor % 100 == 0)
{
await ctx.ReportProgressAsync("import", cursor, Total, ct);
await ctx.SaveCheckpointAsync(new IngestState(cursor), ct);
}
}
}
}
public sealed record IngestState(int Cursor);
Wiring (Program.cs)
builder.AddDloizidesJobs(jobs =>
{
jobs.AddJob<IngestJob>();
jobs.UseEntityFrameworkStore<AppDbContext>(); // from Dloizides.Jobs.EntityFrameworkCore
jobs.Configure(o => o.PollInterval = TimeSpan.FromSeconds(5));
});
Trigger a run, and read status for the console:
await trigger.TriggerAsync("ingest", JobTriggerSources.Manual, user.Sub, tenantId, argument: null, ct);
var status = await statusQuery.GetAsync("ingest", ct); // §8 UI shape: phase, %, checkpoint, stale, timeline
Public API surface
| Type | Role |
|---|---|
ICheckpointableJob |
The job you implement — Name, Cadence, RunAsync(IJobContext, ct). |
IJobContext |
Handed to the body: LoadCheckpoint<T>(), SaveCheckpointAsync<T>(), ReportProgressAsync(phase, done, total, ct). Heartbeat is automatic. |
IJobStore |
The persistence seam (every mutation is compare-and-set; 0 rows = benign no-op). Supplied by a storage package. |
IJobRunner |
Hosted service: poll → single-flight claim → lease + auto-heartbeat → run → checkpoint → CAS complete → reclaim-and-resume. RunOnceAsync is the deterministic seam. |
IJobStalenessMonitor / IJobStalenessAlarm |
The watchdog and where its alarm goes (default logs a warning). |
IJobStatusQuery |
The §8 UI JSON for the running-jobs console (the poll path). |
IJobStatusBackplane |
Opt-in real-time PUSH fan-out (PublishAsync + Subscribe). None (default) / InMemory in core; Postgres in the EF package. Selected by Jobs:Status:Backplane. |
IJobTrigger |
On-demand trigger with provenance + single-flight. |
JobRun, JobRunOutcomes, JobTriggerSources, JobCadence |
The model + vocabulary. |
AddDloizidesJobs(...) |
The one adoption entrypoint. |
Real-time status (opt-in)
Status is durable and pollable by default. To PUSH changes live, select a backplane and (for the browser
wire) add Dloizides.Jobs.AspNetCore:
builder.AddDloizidesJobs(jobs =>
{
jobs.AddJob<IngestJob>();
jobs.UseEntityFrameworkStore<AppDbContext>();
jobs.UsePostgresStatusBackplane<AppDbContext>(); // or jobs.UseInMemoryStatusBackplane()
});
"Jobs": { "Status": { "Backplane": "Postgres", "Wire": "Sse" } }
Persist first, push second. The runtime writes to the store exactly as before and publishes a lightweight
event after the write commits (at enqueue / claim / progress / checkpoint / complete), only when the
compare-and-set actually landed. Push is never load-bearing — a dropped notification heals on the next poll,
so a push client and a poll client always read the same JobStatus. Adding a new transport is a
AddStatusBackplane(key, factory) call plus one config value; the core resolver never changes.
Guarantees
- Single-flight — one queued-or-running run per job name across every replica (a DB constraint, via the store).
- Lease + heartbeat — a dead owner's run becomes reclaimable; a live owner keeps its claim across missed beats.
- Resume — a reclaimed run continues from its last checkpoint, not from scratch.
- Conflict-safe — every transition is compare-and-set; "0 rows affected" is a benign no-op, never a poll-killing throw.
- Watched — a job overdue past its cadence raises an alarm.
- Author responsibility — jobs must be idempotent per checkpoint (re-running the tail after the last checkpoint is safe).
Standard: BaseClient/docs/code-standards/background-jobs.md.
License
MIT
Pause switch (1.4, JOBS-CTL-1d)
Jobs:Paused lists job names that must not run unattended. It is read live through IOptionsMonitor,
so no restart is needed.
- A
scheduled/systemtrigger of a paused job returnsJobTriggerStatus.Pausedand queues nothing. - A
manual/apitrigger still runs it once and recordscompleted/failedas usual. - An unattended run queued before the pause landed is finalised
cancelledby the runner, not executed. - The meter exports
jobs_paused{job,service}(1 paused, 0 not) for every registered job. - A service's own timer loop can inject
IJobPauseSwitchand skip its tick.
Source: the per-cluster jobs-control ConfigMap, rendered from personalServerNotes/jobs/registry.yml,
mounted as a volume at /etc/jobs-control:
data:
jobs-control.json: |
{ "Jobs": { "Paused": [ "aml-pep-refresh" ] } }
builder.Configuration.AddDloizidesJobsControl(); // before AddDloizidesJobs; polling watcher
builder.AddDloizidesJobs(jobs => { /* ... */ });
Mount it as a volume, not via envFrom: environment variables are fixed at pod start. kubelet refreshes a
mounted ConfigMap within about a minute; the polling watcher picks the change up within ~4 s after that.
The file source fails OPEN: an invalid jobs-control.json at boot or on reload is logged as a warning and treated as empty (nothing paused); the host still starts. The bare services.AddDloizidesJobs(configure) overload without a section binds Jobs:Paused from the container's IConfiguration when one is registered.
| 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
- Microsoft.Extensions.Configuration.Binder (>= 8.0.2)
- Microsoft.Extensions.Configuration.Json (>= 8.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options (>= 8.0.2)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
-
net8.0
- Microsoft.Extensions.Configuration.Binder (>= 8.0.2)
- Microsoft.Extensions.Configuration.Json (>= 8.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options (>= 8.0.2)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Dloizides.Jobs:
| Package | Downloads |
|---|---|
|
Dloizides.Jobs.EntityFrameworkCore
Entity Framework Core store for Dloizides.Jobs: JobRun mapping (jsonb Checkpoint/Progress), the single-flight partial unique index, and the compare-and-set ExecuteUpdate transitions that make reclaim-vs-complete a benign no-op instead of a poll-killing throw. Includes the Postgres LISTEN/NOTIFY status backplane for cross-pod real-time PUSH (no new infra). |
|
|
Dloizides.Jobs.AspNetCore
ASP.NET Core real-time status wire for Dloizides.Jobs: MapJobStatusStream mounts an SSE and/or WebSocket endpoint that subscribes to the job status backplane and pushes authoritative JobStatus snapshots to connected browsers. Persist-first-push-second — each event re-reads the durable status, so push and poll clients agree. |
GitHub repositories
This package is not used by any popular GitHub repositories.