Zmplr.Jobs
2026.2.1
dotnet add package Zmplr.Jobs --version 2026.2.1
NuGet\Install-Package Zmplr.Jobs -Version 2026.2.1
<PackageReference Include="Zmplr.Jobs" Version="2026.2.1" />
<PackageVersion Include="Zmplr.Jobs" Version="2026.2.1" />
<PackageReference Include="Zmplr.Jobs" />
paket add Zmplr.Jobs --version 2026.2.1
#r "nuget: Zmplr.Jobs, 2026.2.1"
#:package Zmplr.Jobs@2026.2.1
#addin nuget:?package=Zmplr.Jobs&version=2026.2.1
#tool nuget:?package=Zmplr.Jobs&version=2026.2.1
Zmplr.Jobs
Zmplr.Jobs is a lightweight job scheduler for .NET. Register typed handlers at application startup, run them once, on a cron schedule, or only when triggered, and observe them through a stable HTTP DTO API under /zmplr.
| Host | TFM | /zmplr surface |
|---|---|---|
| ASP.NET Core | net9.0 |
MVC controllers (MapControllers) |
| Classic ASP.NET / IIS (OWIN) | net48 |
app.UseZmplrJobs(services) |
Job definitions and schedules stay in-process (no durable queue / crash recovery). Run results are persisted to a JSON history file so the API and monitor UI can show history across restarts.
It is deliberately small. If you need durable queues, multi-server workers, or schedule recovery after downtime, use Hangfire, Quartz.NET, or a message bus instead.
What it does
- Startup registration — declare jobs when the host starts via
AddZmplrJobs - Typed handlers — implement
IJobHandlerorIJobHandler<TArgs>; each run executes inside a DI scope - One-time jobs — run once after the application has started (optional)
- Recurring jobs — 5-field cron schedules via NCrontab
- Manual jobs — never auto-scheduled; run via HTTP trigger or
IZmplr.Enqueue(in-memory FIFO, one at a time) - Runtime enqueue —
IZmplr.Enqueue(...)works for any registered job kind (queues if busy); optional per-run arguments - Single-flight / serial execution — at most one concurrent run per job (enqueue queues extras)
- Persisted run history — status, timing, exception message, and args JSON per run, stored in
zmplr-history.json(matched by job name after restart) - HTTP monitor API — list jobs, inspect runs, trigger a job (optional JSON body args), cancel a running/pending work item; optional Bearer auth via host policies
- Host-native engine —
BackgroundServicepolling on a one-second tick afterApplicationStarted
Mental model
| Concept | Meaning |
|---|---|
| Job | Named definition registered at startup (handler type, kind, optional cron) |
| Work item | One execution of a job (status, start/stop, error message, optional args JSON) |
| Job engine | Hosted service that starts due jobs and accepts manual triggers |
| Handler | Your IJobHandler / IJobHandler<TArgs> resolved from DI for that run |
Jobs are definitions. Work items are runs. The monitor API exposes both as plain DTOs so out-of-process UIs (or scripts) can watch the process without taking a dependency on internal types.
What it does and does not do
Zmplr is intentionally not a replacement for full job platforms. Compared with Hangfire, Quartz.NET, Azure Functions, or similar:
| Capability | Zmplr.Jobs | Typical alternatives |
|---|---|---|
| Durable job queue / schedule recovery | No — jobs are re-declared at startup; missed ticks are not replayed | SQL/Redis job stores |
| Run-result history for API/UI | Yes — JSON file by job name (default on) | Often part of the job store |
| Runtime enqueue | Yes — in-process IZmplr.Enqueue with per-job FIFO (not durable across restarts) |
Hangfire, queues |
| Crash recovery of in-flight / pending work | No — stale Running/Pending rows become Failed on next start |
Common |
| Retries with backoff | No built-in retry policy | Common |
| Distributed / multi-instance workers | No — one process | Clustering, distributed locks |
| Change schedules via API | No — cron is fixed at registration | Often yes |
| Expression / lambda job registration | No — handlers are types implementing IJobHandler / IJobHandler<TArgs> |
Hangfire expressions, etc. |
| Built-in dashboard UI | No — only the /zmplr JSON API (optional external monitor) |
Hangfire dashboard, etc. |
| Authentication on monitor endpoints | Opt-in — host configures JWT (etc.) and RequireAuthorization policies |
Often packaged or documented |
| Continuations, batches, recurring with calendar complexity | No | Richer platforms |
Use Zmplr when you want simple background work inside a single process (ASP.NET Core or classic ASP.NET via Generic Host + OWIN), typed handlers, cron or trigger-on-demand, runtime enqueue with serial queues, ops-visible run history, and a small HTTP surface for tooling.
Do not use Zmplr when pending work must survive restarts, run across multiple instances, or require delivery guarantees beyond the current process.
Requirements
- net9.0 — ASP.NET Core host; package references
Microsoft.AspNetCore.Appand registers/zmplrcontrollers. CallMapControllers()(or equivalent). OptionalRequireAuthorizationuses host JWT policies. - net48 — .NET Framework 4.8 Generic Host (
Microsoft.Extensions.Hosting) plus OWIN for/zmplr. Start withZmplrHost.Start(...)fromApplication_Start, stop withZmplrHost.Stop()fromApplication_End, and callapp.UseZmplrJobs(ZmplrHost.Services)in OWINStartup. OptionalRequireAuthorizationuses host-registeredIZmplrAuthorizationService(e.g.AuthenticatedUserZmplrAuthorizationServicefor Windows auth).
Install
dotnet add package Zmplr.Jobs
Quick start
1. Implement a handler
public sealed class HeartbeatHandler : IJobHandler
{
public Task ExecuteAsync(JobContext context, CancellationToken cancellationToken)
{
// context.JobId, context.JobName, context.WorkItemId
return Task.CompletedTask;
}
}
public sealed record NotifyOrderArgs(Guid OrderId, string Email);
public sealed class NotifyOrderHandler : IJobHandler<NotifyOrderArgs>
{
public Task ExecuteAsync(JobContext context, NotifyOrderArgs args, CancellationToken cancellationToken)
{
// args from Enqueue / HTTP trigger body
return Task.CompletedTask;
}
}
2. Register jobs at startup
builder.Services.AddZmplrJobs(jobs =>
{
// Runs once after the app has started
jobs.AddOneTime<StartupPingHandler>("startup-ping", group: "system");
// Every minute (5-field cron: minute hour day-of-month month day-of-week)
jobs.AddRecurring<HeartbeatHandler>("heartbeat", "* * * * *", group: "system");
// Never auto-scheduled; trigger or IZmplr.Enqueue (in-memory FIFO)
jobs.AddManual<SendEmailHandler>("send-email", group: "demo");
jobs.AddManual<NotifyOrderHandler>("notify-order", group: "demo");
});
Classic ASP.NET / IIS (net48)
// OWIN Startup.Configuration
ZmplrHost.Start(
jobs =>
{
jobs.AddRecurring<HeartbeatHandler>("heartbeat", "*/5 * * * *", group: "system");
jobs.RequireAuthorization(readPolicy: "Zmplr.Read", writePolicy: "Zmplr.Write");
},
HttpRuntime.AppDomainAppPath,
services => services.AddSingleton<IZmplrAuthorizationService, AuthenticatedUserZmplrAuthorizationService>());
app.UseZmplrJobs(ZmplrHost.Services);
// Global.asax Application_End
ZmplrHost.Stop();
Point the standalone monitor at the site base URL (e.g. http://localhost:48000). /zmplr includes basic CORS for browser clients. When RequireAuthorization is set, register an IZmplrAuthorizationService in the host (see Authentication and authorization).
3. Enqueue from application code
public class OrdersController(IZmplr zmplr) : ControllerBase
{
[HttpPost("{id}/notify")]
public IActionResult Notify(Guid id)
{
var workItemId = zmplr.Enqueue<NotifyOrderHandler, NotifyOrderArgs>(
new NotifyOrderArgs(id, "customer@example.com"));
// or: zmplr.Enqueue("notify-order", new { orderId = id, email = "customer@example.com" });
return Accepted(new { workItemId });
}
}
If the job is already running, further enqueues become Pending and run in order when the current run finishes. Pending items are not recovered after process restart.
4. Map controllers
var app = builder.Build();
app.MapControllers();
app.Run();
AddZmplrJobs registers the job repository, hosted JobEngine, IZmplr, and adds the package’s controller assembly as an application part. Your host still owns middleware, CORS, and auth (see Authentication and authorization).
Job registration API
services.AddZmplrJobs(jobs =>
{
jobs.AddOneTime<THandler>(string name, bool runOnStart = true, string group = null);
jobs.AddManual<THandler>(string name, string group = null);
jobs.AddRecurring<THandler>(string name, string cronExpression, string group = null);
jobs.HistoryFile("zmplr-history.json"); // default; under content root
// jobs.DisableHistory(); // in-memory only for this process
// jobs.RequireAuthorization(readPolicy: "Zmplr.Read", writePolicy: "Zmplr.Write");
});
| Method | Behavior |
|---|---|
AddOneTime |
Kind OneTime. With runOnStart: true (default), becomes eligible once after startup. After that run, it is not auto-scheduled again (you can still trigger it). |
AddManual |
Kind Manual. Never auto-scheduled. HTTP trigger / IZmplr.Enqueue start a run; additional enqueues wait in an in-memory FIFO queue. |
AddRecurring |
Kind Recurring. Uses NCrontab. After each run — success or failure — the next occurrence is calculated. IZmplr.Enqueue can also queue extra runs. |
group |
Optional string on every registration method; exposed as JobDto.Group for monitor grouping. Null/blank means ungrouped. |
HistoryFile |
JSON path file name under the host content root for run results (default zmplr-history.json). Keeps the newest 100 runs per job name. |
DisableHistory |
Turns off disk persistence; runs remain visible only until the process exits. |
RequireAuthorization |
Opt-in policies for /zmplr. readPolicy applies to GET endpoints; writePolicy to trigger/cancel. Null policies leave those endpoints anonymous. On ASP.NET Core the host configures JWT policies + middleware; on net48 register IZmplrAuthorizationService (see Authentication and authorization). |
Handlers are registered with TryAddScoped<THandler>(). Each execution creates an async DI scope and resolves the handler from that scope. THandler must implement IJobHandler or IJobHandler<TArgs>.
Inject IZmplr to enqueue:
| Call | Behavior |
|---|---|
Enqueue(name) |
Queue a run with no args |
Enqueue(name, args) |
Queue a run; args is JSON-serialized onto the work item |
Enqueue<THandler>(name?) |
Ensure manual job exists, then enqueue |
Enqueue<THandler, TArgs>(args, name?) |
Ensure manual job exists, enqueue with typed args |
TryEnqueue(...) |
Same as above without throwing on missing job |
Returns the work item id (Pending until it starts). Typed handlers receive deserialized args (or default when none were supplied).
History is keyed by job name (registration names must stay stable). Job GUIDs still change every process start; after restart, past runs are re-attached to the newly created job with the same name. In-flight Running rows from a crashed process are loaded as Failed with a ProcessTerminated message.
Cron format
Five fields only, as understood by NCrontab:
* * * * *
│ │ │ │ │
│ │ │ │ └── day of week (0–6, Sunday = 0)
│ │ │ └──── month (1–12)
│ │ └────── day of month (1–31)
│ └──────── hour (0–23)
└────────── minute (0–59)
Examples: * * * * * (every minute), 0 */6 * * * (every 6 hours on the hour), 30 2 * * 1 (02:30 on Mondays).
Invalid expressions throw at host startup when jobs are materialized.
Execution behavior
- The engine waits until
IHostApplicationLifetime.ApplicationStarted. - Every ~1 second it asks each job
TimeToRun()and starts eligible jobs that are not already running. - For a run it creates a work item, opens a DI scope, resolves the handler, and calls
ExecuteAsyncwith a linked cancellation token (host shutdown + work-item cancel). - Outcomes:
- Success → work item
Succeeded OperationCanceledException→Canceled- Other exceptions →
Failed,ErrorMessageset toExceptionType: Message, then logged by the engine
- Success → work item
- Recurring jobs always reschedule after a run (including failures). One-time jobs clear
NextRunafter a run.
Trigger (POST /zmplr/jobs/{id}/trigger) starts a job immediately, bypassing schedule eligibility, but still enforces single-flight: if the job is already running you get 409 Conflict. An optional JSON body is stored as that run’s args (and passed to IJobHandler<TArgs>).
Cancel (POST /zmplr/workitems/{id}/cancel) cancels the work item’s token. Your handler must honor cancellationToken for cancel to have effect. Terminal work items (Succeeded / Failed / Canceled) return 409.
HTTP monitor API
Base route: /zmplr. Authentication is opt-in via RequireAuthorization. Without policies, endpoints are anonymous — protect them in the host if exposed.
| Method | Path | Auth policy (when configured) | Description |
|---|---|---|---|
GET |
/zmplr/jobs |
Read | All jobs (DTO projection, ordered by name) |
GET |
/zmplr/jobs/{id} |
Read | Single job |
POST |
/zmplr/jobs/{id}/trigger |
Write | Start now (202 Accepted, or 404 / 409); optional JSON body = args |
GET |
/zmplr/workitems |
Read | All work items; optional ?jobId={guid} |
GET |
/zmplr/workitems/{id} |
Read | Single work item |
POST |
/zmplr/workitems/{id}/cancel |
Write | Request cancel (200, or 404 / 409) |
Job DTO (shape)
Each job includes:
- Identity:
id,name,group,kind,cronExpression,nextRun(UTC) - Live state:
isRunning,lastStatus,lastErrorMessage runs— up to the 50 most recent work items for that job, each withid,status,startTime(UTC),stopTime(UTC),errorMessage,args(JSON text or null)argsSchema— when the handler isIJobHandler<TArgs>, a list of{ name, type }fields (string|number|boolean|guid|datetime|json) for monitor UIs; empty when the job takes no args
Failed runs keep their exception summary on the individual run, not only on the last error field.
Timestamps (nextRun, work-item startTime / stopTime) are stored and serialized as UTC (DateTimeKind.Utc). Cron schedules are still evaluated in the job Clock time zone; only the resulting nextRun instant is converted to UTC.
JSON uses ASP.NET Core defaults from your host (enum string converters, etc., if you configure them).
Authentication and authorization
Zmplr does not ship a login UI, token issuer, or identity provider. Calling RequireAuthorization tells Zmplr which policy names to apply to /zmplr actions.
| Host | How policies are enforced |
|---|---|
| ASP.NET Core (net9) | Host configures authentication (typically JWT Bearer) and authorization policies. Zmplr applies standard [Authorize] filters. |
| Classic ASP.NET / OWIN (net48) | Host registers IZmplrAuthorizationService in DI (e.g. AuthenticatedUserZmplrAuthorizationService). OWIN /zmplr calls it when policy names are set. Missing service with policies configured → 500. |
In-process IZmplr.Enqueue(...) is not HTTP and is not covered by these policies — only the monitor API is.
What gets protected
| Policy slot | Endpoints |
|---|---|
Read (ReadPolicy) |
GET /zmplr/jobs, GET /zmplr/jobs/{id}, GET /zmplr/workitems, GET /zmplr/workitems/{id} |
Write (WritePolicy) |
POST /zmplr/jobs/{id}/trigger, POST /zmplr/workitems/{id}/cancel |
- Both policies unset (default) → all
/zmplrendpoints stay anonymous. - Only
WritePolicyset → GETs stay open; trigger/cancel require auth that satisfies the write policy. - Only
ReadPolicyset → GETs require auth; trigger/cancel stay anonymous (unusual; prefer setting write as well). - Both set → full protection for the monitor API.
Failed auth returns 401 Unauthorized (no/invalid identity) or 403 Forbidden (authenticated but policy failed). OPTIONS (CORS preflight) is not authorized.
Classic ASP.NET / OWIN (net48)
On net48, Zmplr does not plug into ASP.NET’s [Authorize] pipeline. Authorization is three optional host hooks in DI; the OWIN /zmplr surface decides what to do based on which ones you register.
| Host registers | What Zmplr does |
|---|---|
IZmplrAuthorizationService (required if RequireAuthorization is set) |
For each protected /zmplr request, call Authorize(principal, policyName, …). Missing service with policies configured → 500. |
IZmplrBearerTokenValidator (optional) |
If the request has Authorization: Bearer …, turn the token into an IPrincipal before authorization. |
IZmplrAuthenticateService (optional) |
Enable Zmplr’s built-in login endpoint at the fixed path POST /zmplr/authentication/authenticate. |
How the principal is established is up to the host: IIS/Windows auth, Bearer tokens (via the validator), or anything else already on HttpContext. You only need the authenticate service if you want that specific /zmplr login route.
POST /zmplr/authentication/authenticate
This path is defined by Zmplr, not by your application’s routing. You do not map or configure the URL yourself. When UseZmplrJobs runs:
- If
IZmplrAuthenticateServiceis registered —POST /zmplr/authentication/authenticateaccepts{ "username", "password" }, calls yourAuthenticate(...), and returns a JSON string token (or 401 on failure). - If it is not registered — the same path responds 404 (
authenticate_not_configured).
Your class can be named anything (ZmplrJwtAuthService, etc.); only the interface registration matters. Token issuance logic lives in your implementation; Zmplr only wires the HTTP endpoint to it.
Authorization does not depend on this endpoint. Clients can obtain a Bearer token from your own API, an IdP, or paste one into the monitor — as long as IZmplrBearerTokenValidator / IZmplrAuthorizationService accept the resulting principal.
Example: IIS / Windows authentication
ZmplrHost.Start(
jobs =>
{
// ...register jobs...
jobs.RequireAuthorization(readPolicy: "Zmplr.Read", writePolicy: "Zmplr.Write");
},
HttpRuntime.AppDomainAppPath,
services => services.AddSingleton<IZmplrAuthorizationService, AuthenticatedUserZmplrAuthorizationService>());
AuthenticatedUserZmplrAuthorizationService allows any authenticated IPrincipal (policy names are labels only). Implement IZmplrAuthorizationService yourself to map policy names to roles, AD groups, or other rules. No authenticate or Bearer hooks needed when IIS already authenticates the user.
Example: Bearer tokens (+ optional Zmplr login endpoint)
ZmplrHost.Start(
jobs =>
{
// ...register jobs...
jobs.RequireAuthorization(readPolicy: "Zmplr.Read", writePolicy: "Zmplr.Write");
},
HttpRuntime.AppDomainAppPath,
services =>
{
services.AddSingleton<MyJwtAuth>(); // host-owned JWT mint/validate + policy checks
services.AddSingleton<IZmplrBearerTokenValidator>(sp => sp.GetRequiredService<MyJwtAuth>());
services.AddSingleton<IZmplrAuthorizationService>(sp => sp.GetRequiredService<MyJwtAuth>());
// Optional: enables POST /zmplr/authentication/authenticate → MyJwtAuth.Authenticate
services.AddSingleton<IZmplrAuthenticateService>(sp => sp.GetRequiredService<MyJwtAuth>());
});
With the authenticate registration above, the monitor (or any client) can use auth URL /zmplr/authentication/authenticate. Without it, point the monitor at another login URL you own, or paste a Bearer token.
ASP.NET Core — JWT Bearer
Step 1 — Add JWT Bearer packages
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
(Your host already references the shared ASP.NET Core framework; this package wires the JWT Bearer handler.)
Step 2 — Configure authentication and policies
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var jwt = builder.Configuration.GetSection("Jwt");
var signingKey = jwt["SigningKey"]
?? throw new InvalidOperationException("Configure Jwt:SigningKey.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt["Issuer"],
ValidateAudience = true,
ValidAudience = jwt["Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(signingKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
builder.Services.AddAuthorization(options =>
{
// Example claim model: claim type "zmplr" with value "read" and/or "write".
// A write token should also satisfy read (list + trigger with one token).
options.AddPolicy("Zmplr.Read", policy =>
policy.RequireAuthenticatedUser()
.RequireClaim("zmplr", "read", "write"));
options.AddPolicy("Zmplr.Write", policy =>
policy.RequireAuthenticatedUser()
.RequireClaim("zmplr", "write"));
});
Example appsettings.json (dev symmetric key — use secrets / Key Vault in production):
{
"Jwt": {
"Issuer": "my-app",
"Audience": "zmplr-api",
"SigningKey": "replace-with-a-long-random-secret-at-least-32-chars"
}
}
You can instead validate tokens from Azure AD, Auth0, IdentityServer, etc. by configuring AddJwtBearer (or OpenId Connect) against that authority. Policy requirements then use whatever claims your IdP issues (roles, scope, permissions, …).
Step 3 — Opt Zmplr into those policies
builder.Services.AddZmplrJobs(jobs =>
{
jobs.RequireAuthorization(
readPolicy: "Zmplr.Read",
writePolicy: "Zmplr.Write");
// or:
// jobs.RequireAuthorization(o =>
// {
// o.ReadPolicy = "Zmplr.Read";
// o.WritePolicy = "Zmplr.Write";
// });
jobs.AddManual<NotifyOrderHandler>("notify-order");
// … other jobs …
});
Policy names must match what you registered in AddAuthorization. Zmplr only stores the names and applies AuthorizeFilter to the matching actions.
Step 4 — Enable the auth middleware
Order matters: authentication and authorization must run before endpoints are mapped.
var app = builder.Build();
app.UseCors(/* allow your monitor origin; include Authorization via AllowAnyHeader or explicit */);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
If you use the optional monitor SPA from another origin, CORS must allow the Authorization header (e.g. AllowAnyHeader() or .WithHeaders("Authorization", "Content-Type")).
Step 5 — Call /zmplr with a Bearer token
GET /zmplr/jobs HTTP/1.1
Host: localhost:5170
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
curl -s http://localhost:5170/zmplr/jobs \
-H "Authorization: Bearer $TOKEN"
curl -s -X POST http://localhost:5170/zmplr/jobs/{job-id}/trigger \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"orderId":"...","email":"ops@example.com"}'
Common setups
Write-only protection (GETs public on a private network)
jobs.RequireAuthorization(writePolicy: "Zmplr.Write");
Full API locked down (recommended when /zmplr is reachable from browsers or the internet)
jobs.RequireAuthorization(readPolicy: "Zmplr.Read", writePolicy: "Zmplr.Write");
Roles instead of a custom claim
options.AddPolicy("Zmplr.Write", p =>
p.RequireAuthenticatedUser().RequireRole("JobsAdmin"));
Scopes from an OIDC access token (claim type often "scope" or "scp" depending on IdP):
options.AddPolicy("Zmplr.Write", p =>
p.RequireAuthenticatedUser()
.RequireAssertion(ctx =>
ctx.User.FindAll("scope").Any(c => c.Value.Split(' ').Contains("zmplr.write"))
|| ctx.User.FindAll("scp").Any(c => c.Value == "zmplr.write")));
What stays unauthenticated
| Surface | Protected by RequireAuthorization? |
|---|---|
/zmplr/* HTTP API |
Yes, when policies are set |
IZmplr.Enqueue / handlers inside the process |
No — same trust boundary as your app code |
| Your own controllers / minimal APIs | No — configure separately |
Do not expose an unauthenticated token minting endpoint in production. The sample host’s POST /demo/token (username/password → JSON access_token, or a host may return a plain token string) is for local demos only.
Monitor UI
The optional zmplr.monitor.app stores a per-source auth URL and Bearer token. Use Auth → username/password → Sign in & save token (posts { username, password }, accepts a plain token string or JSON access_token), or paste a token manually. List and trigger requests then send Authorization: Bearer ….
On a net48 host that registered IZmplrAuthenticateService, set the auth URL to /zmplr/authentication/authenticate (Zmplr’s built-in endpoint). Otherwise use any other login URL your app exposes (e.g. ExampleWeb’s POST /demo/token).
Working sample
Zmplr.Jobs.ExampleWeb wires JWT Bearer, Zmplr.Read / Zmplr.Write, RequireAuthorization, middleware, CORS, and POST /demo/token with demo credentials (demo / demo). See that project’s README for curl examples.
Comparison cheat sheet
Hangfire — persistent storage, dashboard, runtime enqueue, retries, multi-server. Heavier operational footprint. Choose Hangfire when durability and dynamic enqueue matter.
Quartz.NET — mature scheduling, calendars, clustering with a job store. Choose Quartz when you need rich scheduling semantics and persistence.
Coravel / IHostedService timers — fine for a few app-local tasks with little ops surface. Zmplr sits nearby but adds named jobs, cron, trigger/cancel, and a DTO monitor API aimed at external monitors.
Message queues (Azure Service Bus, RabbitMQ, …) — choose these for cross-service work, competing consumers, and delivery guarantees. Zmplr is in-process orchestration, not a bus.
Design choices (and trade-offs)
Schedules in memory, results on disk
Job definitions and due times are not recovered from storage — you re-register them at startup. Completed runs are written to a JSON history file so monitors can still show history after a restart, without adopting a full job-store stack.
Handlers over expressions
Jobs are IJobHandler / IJobHandler<TArgs> types registered in DI. You get constructor injection and testability without serializing lambdas. Per-run arguments are JSON on the work item.
Register at launch
The set of jobs is part of application composition. There is no API to invent new job types at runtime. Trigger/cancel operate on already registered jobs.
Stable DTO API
/zmplr returns records suitable for third-party monitors. An example UI is available from the GitHub repo (see Monitor UI); it is not shipped inside this package.
Recurring jobs keep going after failure
A failed run is recorded on the work item; the next cron occurrence is still scheduled. There is no automatic retry-with-backoff of the same due time.
Monitor UI
The React monitor is available in two forms:
| Package / folder | Role |
|---|---|
Zmplr.Jobs.Monitor NuGet |
Optional embed: AddZmplrMonitor() + MapZmplrMonitor() (default /zmplr-ui, configurable PathPrefix) |
zmplr.monitor.app |
Standalone Vite app (same UI; source for the NuGet build) |
Both support multi-host API sources (empty base URL = this origin; add remotes by absolute URL).
Embedded in your app
builder.Services.AddZmplrMonitor();
// …
app.MapZmplrMonitor();
See Zmplr.Jobs.Monitor/README.md.
Standalone
- Run a host with
Zmplr.Jobs(e.g. ExampleWeb onhttp://localhost:5170). - Allow CORS from the monitor origin if the UI is on another origin.
- In
zmplr.monitor.app:npm installthennpm run dev. - Open http://localhost:3000; sign in against each protected host as needed.
Full instructions: zmplr.monitor.app/README.md
Package contents
- Core scheduler types and
JobEnginehosted service (net9.0andnet48) - DI extensions:
AddZmplrJobs/ZmplrJobsBuilder(includingRequireAuthorization) - Typed handlers:
IJobHandlerandIJobHandler<TArgs>with per-run args on work items / DTOs - JSON run-history store (
zmplr-history.jsonby default) - ASP.NET Core MVC controllers for
/zmplr(application part) - Classic ASP.NET / OWIN
/zmplrsurface (ZmplrHost,UseZmplrJobs, optional auth interfaces)
Not included: identity provider / token issuer. Optional UI: separate package Zmplr.Jobs.Monitor (ASP.NET Core host).
Project home: mtanneryd.github.io/zmplr
Source and samples: github.com/mtanneryd/zmplr-jobs
Versioning
Package versions are produced with GitVersion (same scheme as other Zmplr libraries):
| Branch | Example |
|---|---|
master / main |
2026.2.0 |
develop |
2026.2.0-beta.… |
feature/* |
2026.2.0-alpha.… |
release/* |
2026.2.0-rc.N |
Release notes
2026.2.0
- Multi-target
net9.0+net48— same scheduler engine on ASP.NET Core and classic ASP.NET / IIS - OWIN
/zmplrAPI —ZmplrHost+UseZmplrJobsfor Generic Host under IIS - net48 authorization — host-supplied
IZmplrAuthorizationService(Windows-auth helper included); optionalIZmplrBearerTokenValidator; optionalIZmplrAuthenticateServiceenables fixedPOST /zmplr/authentication/authenticate - CORS — basic CORS on the OWIN
/zmplrsurface for browser monitors - DI — fixed multiple-constructor resolution for
JobController - Package metadata — project website points at the Zmplr home page; repository URL is this source repo
2026.1.0
- Optional
Zmplr.Jobs.Monitorembedded multi-host UI - Opt-in JWT authorization for
/zmplron ASP.NET Core - Typed handlers with per-run args, runtime enqueue, JSON run history
License
Licensed under the Apache License, Version 2.0.
| Product | Versions 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 was computed. 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. |
| .NET Framework | net48 is compatible. net481 was computed. |
-
.NETFramework 4.8
- Microsoft.Extensions.Hosting (>= 8.0.1)
- Microsoft.Owin (>= 4.2.3)
- NCrontab.Signed (>= 3.3.3)
- Owin (>= 1.0.0)
- System.Text.Json (>= 8.0.5)
-
net9.0
- Microsoft.Extensions.Hosting (>= 9.0.0)
- NCrontab.Signed (>= 3.3.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2026.2.1 | 75 | 8/12/2026 |
| 2026.2.0 | 74 | 8/12/2026 |
| 2026.1.0 | 87 | 8/9/2026 |
| 2026.1.0-beta.20260806.27766 | 58 | 8/6/2026 |
| 2026.1.0-beta.20260805.79770 | 49 | 8/6/2026 |
| 2026.1.0-beta.20260805.79017 | 57 | 8/5/2026 |
| 2026.1.0-beta.20260805.74119 | 50 | 8/5/2026 |