Dloizides.Jobs 1.4.1

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

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 / system trigger of a paused job returns JobTriggerStatus.Paused and queues nothing.
  • A manual / api trigger still runs it once and records completed / failed as usual.
  • An unattended run queued before the pause landed is finalised cancelled by 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 IJobPauseSwitch and 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

Version Downloads Last Updated
1.4.1 102 9/27/2026
1.4.0 86 9/27/2026
1.3.2 165 9/22/2026
1.3.1 101 9/22/2026
1.3.0 109 9/22/2026
1.2.0 122 9/13/2026
1.1.0 235 8/14/2026
1.0.0 777 8/14/2026