XactJobs.UI 0.4.0

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

XactJobs

XactJobs lets you schedule and run background jobs with full database transaction support using EntityFrameworkCore. Jobs are saved alongside your business data and only execute if your transaction commits - no more “ghost” jobs on rollback.

Supports SQL Server, PostgreSQL, MySQL, Oracle and SQLite, on .NET 8, 9 and 10.

XactJobs uses EF Core 9 on .NET 8 and 9, and EF Core 10 on .NET 10, so your database provider has to support that version. For MySQL, see which provider to use.

The docs are also on the website, page by page: xactjobs.com.

A Job Is a Unit of Work

The worker saves a job's database changes, not the job. Changes a job makes through the injected TDbContext are saved when the job finishes, in the same transaction that marks it as completed:

  • Exactly once. If a lease expires and another worker runs the job again, only one of the runs can commit, so the changes happen once.
  • No transaction stays open while the job runs, unless the job opens one. The changes wait in EF's change tracker, and are written at the end in one short transaction.
  • Don't call SaveChanges in a job, or call it only inside a transaction you open and don't commit: the worker commits it. Changes saved any other way are permanent right away, even if the job then fails.
  • Returning saves. Returning normally, even early, saves the pending changes and records the job as completed. A job that wants to stop without saving must throw instead.
  • Throwing discards. Any exception discards the pending changes, and the job is retried. To stop a job that can never succeed, e.g. because the data it works on is gone, throw XactJobCancelException: its changes are discarded too, but it isn't retried, and it moves to the history as cancelled, with the exception's message.
public class InvoiceJob(UserDbContext db)
{
    public async Task CreateInvoiceAsync(int orderId)
    {
        var order = await db.Orders.FindAsync(orderId)
            ?? throw new XactJobCancelException($"Order {orderId} doesn't exist anymore");

        db.Invoices.Add(new Invoice(order.Id)); // no SaveChanges: the worker saves it when the job finishes
    }
}

The details are in Exactly-Once Database Effects, and for XactJobCancelException in Failures and Retries.

Status

XactJobs is pre-1.0. The core is tested on all five databases, with an integration test suite running against each one (SQLite in-process, the others in containers): transactional enqueue, claiming and leasing, retries, batches and continuations, and periodic jobs.

Until 1.0:

  • The API can still change between minor versions (0.x). Breaking changes are listed in the release notes, with how to migrate.
  • Schema changes ship as SQL scripts, one per version and database, with upgrade notes in this README. Existing data is always kept.
  • 1.0 freezes the API and the schema under semantic versioning.

Feedback and issue reports are very welcome, especially from production use.

XactJobs was started in 2025, and written without AI. Newer releases are developed with AI, and the changes are reviewed by a human.

Features

The examples below use this simple job:

public static class Greeter
{
    public static void Say(string message) => Console.WriteLine(message);
}

Fire and Forget Jobs

Fire-and-forget jobs are executed once by a background worker, in its next poll. Each worker polls every 6 seconds by default (WithPollingInterval), and the workers of a queue take turns, so with the default 2 workers the queue is polled every 3 seconds. QuickPoll runs a job right away.

// ... other business logic
dbContext.JobEnqueue(
    () => Greeter.Say("Fire-and-forget!"));

dbContext.SaveChanges();

Delayed Jobs

Delayed jobs are executed once after the specified time interval.

dbContext.JobScheduleIn(
    () => Greeter.Say("Delayed!"),
    TimeSpan.FromDays(7));

dbContext.SaveChanges();

Recurring jobs

Recurring jobs run periodically according to the specified cron schedule.

dbContext.JobEnsurePeriodic(
    () => Greeter.Say("Recurring!"),
    "my_recurring_job",
    Cron.Daily());

dbContext.SaveChanges();

If the periodic jobs are known at startup, define them in the options instead:

builder.Services.AddXactJobs<UserDbContext>(options =>
{
    options.WithPeriodicJob(
        () => Greeter.Say("Recurring!"),
        "my_recurring_job",
        Cron.Daily());
});

This is the safe way to do it with several nodes starting at the same time, because the nodes take turns under a lock. JobEnsurePeriodic can't take that lock, since the changes are only saved later by your SaveChanges. If several nodes call it at the same time, it's still safe for an existing job (only the definition saved last runs), but for a new job all of them except the first get a duplicate key error from SaveChanges.

Each run of a periodic job schedules the next one. Each node also checks every 5 minutes (WithPeriodicJobReconciliationInterval) for active periodic jobs without a pending run, and schedules their next run. So a periodic job recovers if its pending row gets lost, and rows added directly to the periodic job table are picked up. They need a valid cron expression, and the same type name, method name and arguments format that XactJobs writes. The run happens at the next time on the cron schedule, missed runs are not caught up.

After a periodic job is deleted, or its definition changes, the pending run of the old definition stays in the table until its scheduled time. A worker then skips it, adding a Skipped row to the history.

Several Jobs at Once

Adding multiple jobs before saving changes will enqueue all jobs in a single transaction.

dbContext.JobEnqueue(() => Greeter.Say("Job 1"));
dbContext.JobEnqueue(() => Greeter.Say("Job 2"));

dbContext.SaveChanges();

Continuations

A continuation is a job that starts when another job is finished. It can have continuations of its own:

dbContext.JobEnqueue<Importer>(x => x.ImportAsync(fileId))
    .ContinueWith<Reports>(x => x.RebuildAsync())
    .ContinueWith<Mailer>(x => x.SendReportAsync());

dbContext.SaveChanges();

By default a continuation runs if the job succeeded. The condition can also be OnFailure or OnFinish, and a continuation can go to another queue:

var import = dbContext.JobEnqueue<Importer>(x => x.ImportAsync(fileId));

import.ContinueWith<Reports>(x => x.RebuildAsync());
import.ContinueWith<Ops>(x => x.AlertAsync(fileId), XactJobContinuation.OnFailure, QueueNames.Priority);
Condition Runs when
OnSuccess (default) all the jobs it waits for succeeded
OnFailure at least one of them failed
OnFinish they are all finished, whether they succeeded or not

A job failed if it ran out of retries, threw XactJobCancelException, was cancelled from the dashboard, or was a continuation that was skipped, so in a chain of OnSuccess continuations, a failure skips the rest of the chain. A continuation whose condition isn't met moves to the history as Skipped, with the reason as its message. Attempts that were retried don't count: a job of a batch that succeeds on its second attempt succeeded.

A job can only be continued before it's saved, because its row can't be changed afterwards. To continue jobs that are saved separately, put them in a batch.

Batches

A batch is a group of jobs whose continuations start when all of them are finished:

var batch = dbContext.JobBatch()
    .Enqueue<InventorySync>(x => x.SyncAsync("leeds"))
    .Enqueue<InventorySync>(x => x.SyncAsync("reno"))
    .ScheduleIn<InventorySync>(x => x.SyncAsync("osaka"), TimeSpan.FromHours(2));

batch.ContinueWith<Reports>(x => x.RebuildAsync())
    .ContinueWith<Mailer>(x => x.SendReportAsync());

batch.ContinueWith<Ops>(x => x.AlertAsync(), XactJobContinuation.OnFailure);

dbContext.SaveChanges();

The jobs of a batch are saved together, with one SaveChanges: once it's saved, a batch can't get more jobs. Continuations can be added at any time, also later by the batch's id (batch.Id). If the batch is already finished, they start right away:

dbContext.JobBatch(batchId)
    .ContinueWith<Audit>(x => x.LogAsync(batchId), XactJobContinuation.OnFinish);

dbContext.SaveChanges();

A batch id that doesn't exist is a finished batch without jobs, so its continuations run as if all of its jobs succeeded.

  • A retry of a job stays in its batch, and a continuation waits for it. A job retried from the dashboard is a new job, outside of the batch.
  • A job of a batch can't be continued on its own: continue the batch. Periodic jobs can't be in a batch, nor be continued.
  • A skipped continuation can be retried from the dashboard: it runs as a new job, outside of any batch.
  • The worker that finishes the last job of a batch wakes up the workers of the continuations' queues, if they run in the same process, and the continuations start as soon as a worker is free. Besides that, each worker checks for continuations of finished batches once per polling interval. The check is one query, which looks at every continuation of the queue that's still waiting, so keep it in mind with many thousands of them.
  • A continuation that waits for its batch is stored with the scheduled time 9999-12-31, so the poll for due jobs never reaches it.
  • With QuickPoll, JobBatch() registers the jobs of the batch for the notification. Their continuations don't need it.
  • The history cleanup, if you turn it on (see Configuration), keeps the history of the batches that continuations still wait for, since it tells if their jobs failed.

Transactions

Jobs participate in DbContext transactions, just like the regular entities in EFCore.

using var tx = dbContext.Database.BeginTransaction();

// some business logic
dbContext.JobEnqueue(() => Greeter.Say("Job 1"));
dbContext.SaveChanges();

// some more business logic
dbContext.JobEnqueue(() => Greeter.Say("Job 2"));
dbContext.SaveChanges();

tx.Commit();

Isolated Queues

XactJobs can be configured to run isolated workers for named queues.

dbContext.JobEnqueue(() => Greeter.Say("Job 1"), QueueNames.Priority);
dbContext.JobEnqueue(() => Greeter.Say("Job 2"), QueueNames.LongRunning);

dbContext.SaveChanges();

A queue's jobs only run on its workers, so each queue needs them in the options: WithPriorityQueue(), WithLongRunningQueue(), or WithIsolatedQueue("name") for a queue of your own (see Configuration).

QuickPoll

QuickPoll can be used if a job should be executed immediately, without waiting for the next poll interval. QuickPoll instance is registered as a scoped service so it can be injected in class constructors, just like the DbContext instances.

public class YourWorkerOrController
{
    private readonly QuickPoll<UserDbContext> _quickPoll;
    private readonly UserDbContext _dbContext;

    public YourWorkerOrController(QuickPoll<UserDbContext> quickPoll)
    {
        _quickPoll = quickPoll;
        _dbContext = quickPoll.DbContext;
    }

    public async Task YourBusinessLogic()
    {
        // Your business logic uses _dbContext, as usual.

        // Enqueue the job through _quickPoll (instead of DbContext)
        _quickPoll.JobEnqueue(() => Greeter.Say("QuickPoll"));

        // Save changes through _quickPoll
        // (this calls DbContext.SaveChanges and notifies the workers)
        _quickPoll.SaveChangesAndNotify(); 
    }
}

This can only work if the workers are running in the same process where the jobs are enqueued (which is the default).

A job enqueued this way is scheduled for the current time of the application. If that's still in the future for the database when the worker polls (the clocks differ, or on SQLite within the same millisecond), the job runs at the next regular poll.

Installation

Install the XactJobs nuget package:

dotnet add package XactJobs

Add Entity Configurations to your DbContext:

public class UserDbContext: DbContext
{
    // Your DbSets...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyXactJobsConfigurations(Database.ProviderName);

        // Apply your entity configurations...
    }
}

Register XactJobs with your service provider

// This registers 2 workers for the default queue
builder.Services.AddXactJobs<UserDbContext>(options =>
{
    options.WithPriorityQueue();     // optional (2 additional workers)
    options.WithLongRunningQueue();  // optional (2 additional workers)
});

Finally create the XactJobs tables in your database. For a new installation, run the install script for your database, which creates the latest schema. To upgrade an existing installation, run the versioned scripts newer than its version, in version order, with the notes below. The install script creates the same schema as all the versioned scripts.

Database New installation Upgrade
SQL Server xact-jobs-sqlserver.sql v0.1.0, v0.1.4, v0.1.5, v0.2.0, v0.3.0, v0.4.0
PostgreSQL xact-jobs-postgresql.sql v0.1.0, v0.1.4, v0.1.5, v0.2.0, v0.3.0, v0.4.0
MySQL xact-jobs-mysql.sql v0.1.0, v0.1.4, v0.1.5, v0.2.0, v0.3.0
Oracle xact-jobs-oracle.sql v0.1.0, v0.1.4, v0.1.5, v0.2.0, v0.3.0
SQLite xact-jobs-sqlite.sql v0.1.0, v0.1.4, v0.1.5, v0.2.0, v0.3.0, v0.4.0

On SQL Server, run the scripts with QUOTED_IDENTIFIER and ANSI_NULLS on, which are SqlClient's and SSMS's defaults (sqlcmd needs -I): the job table has a filtered index.

When upgrading an existing installation, run only the versioned scripts of the newer versions. For v0.1.5, upgrade all the workers first and then run the script, because it drops an index that older versions need.

For v0.2.0, run the script first: it adds the columns for batches and continuations, which v0.2.0 needs. Start using batches and continuations only once all the workers run v0.2.0: a worker of an older version that retries a job of a batch removes the job from its batch, so the batch's continuations could start too early.

For v0.4.0, the script can run before or after the workers are upgraded. On PostgreSQL, SQL Server and SQLite, it rebuilds the indexes of the batch columns, in the job table and the history, so that they hold only the rows of batches and continuations: the other jobs' inserts and deletes don't write to them. There's no script for MySQL, which has no filtered indexes, nor for Oracle, whose indexes already leave out those rows. On SQL Server, run it with QUOTED_IDENTIFIER and ANSI_NULLS on.

For v0.3.0, run the script first: it adds the column for the time a job was originally scheduled for, which v0.3.0 needs. Then upgrade the workers. A worker of an older version that retries a job leaves the column empty, so that retry sees its own retry time as XactJobContext.OriginallyScheduledAt. On SQLite, the script also adds an index on the queue and the scheduled time, for the claims: without it, every claim reads and sorts the whole job table. Except on MySQL, it also adds an index of the periodic job column, with only the runs of periodic jobs, so that pausing a periodic job, resuming it, the reconciliation and the dashboard's Periodic page don't read the whole table. On SQL Server it changes the column from nvarchar(max), which can't be indexed, to nvarchar(450), the type of the periodic job's id, and it's a filtered index: creating it, and writing to the table, needs QUOTED_IDENTIFIER and ANSI_NULLS on, which are SqlClient's defaults (sqlcmd needs -I). The dashboard's API identifies a pending job by its primary key now: see API.

Using EF Migrations

Alternatively, instead of creating the tables with the scripts above, you can include the XactJobs entities in EF migrations like this:

public class UserDbContext: DbContext
{
    // Your DbSets...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyXactJobsConfigurations(Database.ProviderName,
            excludeFromMigrations: false); // <--- key part here

        // Apply your entity configurations...
    }
}

And then create the migration like you normally would:

dotnet ef migrations add XactJobsTables

Several Apps in One Database

On PostgreSQL and SQL Server, XactJobs' tables are in their own schema: xact_jobs on PostgreSQL, XactJobs on SQL Server. Apps that share a database each need their own tables, or their workers would run each other's jobs. Give each app a schema of its own:

modelBuilder.ApplyXactJobsConfigurations(Database.ProviderName, schema: "app_b");

The workers take the schema from the model. With the SQL scripts, replace xact_jobs (or XactJobs) with your schema before you run them; EF migrations use it on their own. On PostgreSQL the name must be lowercase.

On MySQL, Oracle and SQLite there's no schema option: give each app its own database (on Oracle, its own user), which already has its own tables.

Jobs

A job is simply a public method. It can be either a static or an instance method, but it must be public and:

  • it must not be a generic method,
  • it cannot have out or ref parameters,
  • it cannot have parameters that are anonymous functions, delegates, or lambda expressions,
  • if it is an async method, it cannot be async void (it must be async Task),

Job method parameters are evaluated immediately and serialized using System.Text.Json.JsonSerializer. This means parameters can be both primitives or complex objects, although large objects should be avoided as they require more storage and take longer to serialize/deserialize.

Job Examples

Static methods

class EmailHelper
{
    // example 1
    public static void SendEmail(string from, string to, string body)
    {
        // ... mail sending logic
    }

    // example 2
    public static async Task SendEmailAsync(string from, string to, string body, CancellationToken cancellationToken)
    {
        // ... mail sending logic
    }
}

These jobs can be scheduled with:

// example 1
dbContext.JobEnqueue(
    () => EmailHelper.SendEmail("bob@acme.com", "alice@acme.com", "Hello Alice"));

// example 2
dbContext.JobEnqueue(
    () => EmailHelper.SendEmailAsync("bob@acme.com", "alice@acme.com", "Hello Alice", CancellationToken.None));

dbContext.SaveChanges();

If a method accepts a cancellation token, then null or CancellationToken.None should be passed when the job is enqueued.
When the worker executes the job, it will pass the correct stopping token.

In the same way, a method can take an XactJobContext parameter, with null passed when the job is enqueued. The worker passes the context of the run. For now it has OriginallyScheduledAt, the time (UTC) the job was originally scheduled for, which stays the same across its retries:

public async Task SendDailyReportAsync(XactJobContext context, CancellationToken cancellationToken)
{
    var idempotencyKey = $"daily-report-{context.OriginallyScheduledAt:yyyy-MM-dd}";
    // ...
}

dbContext.JobEnsurePeriodic<ReportSender>(
    x => x.SendDailyReportAsync(null!, CancellationToken.None),
    "daily_report",
    Cron.Daily());

For a periodic job it's the cron slot of the run. For a continuation, it's the time its first attempt was claimed, once its batch was finished: the time is saved when the first attempt is claimed, so a second run of that attempt, after its lease expired, gets the same value.

The cron slot and OriginallyScheduledAt are in UTC, because cron expressions are evaluated in UTC. So a daily run at midnight in a time zone east of UTC, written as its UTC time (e.g. 0 0 22 * * * for UTC+2), has the previous day's UTC date. If that's confusing, format the key with the full time, e.g. :yyyyMMddHHmmss.

Instance methods

class EmailSender
{
    private readonly IEmailService _emailService;

    // Constructor with dependencies.
    public EmailSender(IEmailService emailService)
    {
        _emailService = emailService;
    }

    // example 1
    public void SendEmail(string from, string to, string body)
    {
        // ... mail sending logic
    }

    // example 2
    public async Task SendEmailAsync(string from, string to, string body, CancellationToken cancellationToken)
    {
        // ... mail sending logic
    }
}

These jobs can be scheduled with:

// example 1
dbContext.JobEnqueue<EmailSender>(
    x => x.SendEmail("bob@acme.com", "alice@acme.com", "Hello Alice"));

// example 2
dbContext.JobEnqueue<EmailSender>(
    x => x.SendEmailAsync("bob@acme.com", "alice@acme.com", "Hello Alice", CancellationToken.None));

dbContext.SaveChanges();

When a worker is processing an instance job method, it will use the first public constructor to create the instance, and then call the method on that instance. If the constructor has parameters, the worker will use IServiceScopeFactory to create a scope and then attempt to resolve each type using scope.ServiceProvider.GetRequiredService.

This means the dependencies (IEmailService in the example) of the owning class must be registered in the DI service collection. The owning class itself (EmailSender in the example) does not have to be a registered service - it just has to have a public constructor.

Configuration

builder.Services.AddXactJobs<UserDbContext>(options =>
{
    options
        .WithWorkerCount(2)                         // workers polling the queue
        .WithMaxDegreeOfParallelism(8)              // jobs running at the same time, per worker (default: processor count)
     // .WithBatchSize(16)                          // jobs a worker claims and holds at a time (default: twice MaxDegreeOfParallelism, see Polling)
        .WithPollingInterval(6)                     // seconds between polls, per worker
        .WithLeaseDuration(120)                     // seconds, extended automatically while a job is running
        .WithShutdownTimeout(10)                    // seconds running jobs get to finish on stop, before they are cancelled
        .WithPeriodicJobReconciliationInterval(300) // seconds between the checks for periodic jobs without a pending run
        .WithSaveOutsideTransaction(XactJobSaveCheck.Warn); // when a job saves without a transaction: Warn, Throw or Ignore (all the queues)
     // .WithHistoryRetentionPeriodInDays(30)       // deletes the older history every hour (default: the history is kept)
     // .WithJobTagsInMetrics()                     // tags the jobs' metrics with the job's type and method (all the queues)

    // isolated queues have their own settings
    options.WithLongRunningQueue(queue => queue.WithMaxDegreeOfParallelism(2));
});

The values above are the defaults, except for WithMaxDegreeOfParallelism.

By default the history is kept, and XactJobs deletes nothing. WithHistoryRetentionPeriodInDays adds a periodic job, xj_history_cleanup, which deletes the older history every hour, except the history of the batches that continuations still wait for. Set it the same on every node: a node without it removes the cleanup job when it starts. Versions before 0.3.0 always added it, with 30 days: to keep that, call WithHistoryRetentionPeriodInDays(30).

Dashboard

The XactJobs.UI package adds a web dashboard and a REST API to see and manage the jobs.

The dashboard: due, running, succeeded and failed jobs, and the jobs processed per hour

using XactJobs.UI.DependencyInjection;

builder.Services.AddXactJobsUI<UserDbContext>(options =>
{
    options
        .RequireAuthorizationPolicy("JobAdmins") // who can use it, see below
        .WithBasePath("/XactJobs")               // the default
        .WithApiBasePath("/api/XactJobs");       // the default
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapXactJobsUI();       // maps the pages (Razor Pages), the API, and the dashboard's scripts and styles

The dashboard's scripts, styles and fonts (Bootstrap, Bootstrap Icons, Chart.js and htmx) are embedded in the package, and MapXactJobsUI serves them under the dashboard's path, with the same access as the pages. So the dashboard works without internet access, without the static files middleware, and in any environment, whether the application is published or not.

The dashboard follows the light or dark setting of the operating system, and the switch in its navbar chooses light, dark or auto (the operating system's setting) in each browser.

Access

By default the dashboard only accepts requests from the local machine. Choose who can use it:

  • RequireAuthorization() - any signed-in user (the default authorization policy)
  • RequireAuthorizationPolicy("name") - users that satisfy the policy
  • AllowAnonymous() - anyone. Only for development, or when the access is restricted in another way
  • AllowLocalRequestsOnly() - the default. Behind a reverse proxy on the same machine, every request looks local, unless the forwarded headers middleware is configured

The API endpoints that change data require an antiforgery token, which the dashboard sends. If the API is called by clients that don't authenticate with cookies, DisableAntiforgery() turns the check off.

Pages and Actions

  • Dashboard - due, scheduled and running jobs, continuations waiting for their batch, succeeded and failed jobs in the last 24 hours, and a chart per hour
  • Scheduled - the pending and running jobs. A continuation that waits for its batch shows how many of the batch's jobs are left, and jobs of batches link to their batch
    • Run now makes a pending job due now, and the job keeps the time it was scheduled for as XactJobContext.OriginallyScheduledAt. A continuation that waits for its batch can't: it starts when the batch is finished
    • Cancel moves a pending job to the history as cancelled. Running jobs can't be cancelled, and neither can runs of periodic jobs: pause the periodic job instead. A cancelled job of a batch counts as a failed job of the batch, and if it was the last one, the batch's continuations start
  • History - succeeded, failed (out of retries, or cancelled by the job with XactJobCancelException), cancelled from the dashboard, retried attempts, and skipped runs and continuations, with the reason
    • Retry enqueues a failed or cancelled job, or a skipped continuation, again as a new job. A run of a periodic job is enqueued as a one-off job, and a job of a batch or a continuation runs outside of any batch, so it doesn't start continuations. Like a retry by a worker, it keeps the time the job was originally scheduled for
  • Batches - the active batches, with jobs left, and the finished ones, as long as their history is kept, with their progress
    • A batch's page has its jobs left, its continuations and its history, and links to the batch it continues
  • Periodic - the periodic jobs, with their next run
    • Pause removes the pending run, and no runs are scheduled until it's resumed. A paused job stays paused when the application starts again, even if JobEnsurePeriodic or WithPeriodicJob defines it
    • Resume schedules the next run
    • Trigger makes the pending run due now, so the job keeps a single schedule
    • Delete deletes the definition. A periodic job defined in code is added again when the application starts

Scheduled jobs: running, scheduled and retrying jobs, with their queues

A failed job's error message and stack trace

Periodic jobs: their cron expressions and next runs, one of them paused

Active batches: their progress, failed jobs and waiting continuations

A batch: its jobs left, the continuations that wait for it, and its history

A job that is running can't be changed: the dashboard never touches a job a worker has leased.

Pausing and resuming are also available in code, with dbContext.JobSetPeriodicActiveAsync(id, isActive, cancellationToken) followed by SaveChanges.

API

All under the API base path (/api/XactJobs by default):

Method Path
GET /stats Dashboard counts
GET /chart-data?from=&to=&intervalMinutes= Counts per interval (default: the last 24 hours)
GET /jobs?page=&pageSize=&queue= Pending and running jobs
GET /jobs/{id}?queue=&scheduledAt= A pending or running job
POST /jobs/{id}/run-now?queue=&scheduledAt= Make a pending job due now
POST /jobs/{id}/cancel?queue=&scheduledAt= Cancel a pending job
GET /history?category=&page=&pageSize=&queue= History: succeeded, failed, cancelled, retried or skipped
GET /history/{id} A history row
POST /history/{id}/retry Enqueue a failed or cancelled job, or a skipped continuation, again
GET /batches?state=&page=&pageSize= Batches: active or finished
GET /batches/{id} A batch with its jobs, history and continuations
GET /periodic?page=&pageSize= Periodic jobs
GET /periodic/{id} A periodic job
POST /periodic/{id}/pause Pause a periodic job
POST /periodic/{id}/resume Resume a periodic job
POST /periodic/{id}/trigger Run a periodic job now
DELETE /periodic/{id} Delete a periodic job

The actions answer 200 with a message, 404 if the job doesn't exist, or 409 if its state doesn't allow the action (e.g. it's running).

A pending job is identified by its primary key: its id, queue and scheduledAt, as /jobs returns them, with scheduledAt in UTC (e.g. 2026-11-07T10:00:00.1234567Z). On most databases there's no index on the id alone, so a lookup by the id would read the whole job table. Without the queue and the scheduled time, these routes answer 400.

How Jobs Run

Polling

A worker runs up to MaxDegreeOfParallelism jobs at a time, and holds a batch of claimed (leased) jobs: by default twice as many, so that each slot has a job waiting for it. A free slot takes one of the jobs the worker holds, without waiting for a claim. A slow job only occupies its own slot.

If a poll fills the batch there is likely a backlog, so the worker claims again once fewer jobs wait than it can run, instead of once for each job that finishes: when the jobs it holds are done, or after a second if some are still running. Otherwise it waits for the polling interval or a QuickPoll notification. Polling can stop only if all the slots are occupied, so long-running jobs are best kept in their own queue.

WithBatchSize changes the batch. A larger one saves claims, which helps short jobs: on PostgreSQL, 2 workers running 4 no-op jobs at a time each drained a backlog about twice as fast with a batch of 100 as with no batch. The cost is that the jobs a worker holds wait for its slots, also when another worker is idle, and wait for their leases to expire if the worker crashes. At or below MaxDegreeOfParallelism, there's no batch: a worker only claims jobs it can start immediately, so they never wait behind a slow job while another worker is free. When the worker stops, the jobs it holds that haven't started are released right away, for the other workers.

Throughput

On a Hetzner Cloud CX33 (cost-optimized, 4 shared vCPUs, AMD EPYC Rome, 8 GB of RAM), with PostgreSQL 16 in a container on the same machine (its default settings, so every commit waits for the disk), .NET 10 and EF Core 10, XactJobs drained a backlog of 20,000 no-op jobs at 2,300 to 2,600 jobs per second. The best setting was 2 workers, MaxDegreeOfParallelism 4 and WithBatchSize(100). Each job is still its own transaction: its changes, the delete of its row and its history row commit together.

With real jobs, their own work sets the pace. The benchmark is in src/XactJobs.Benchmarks, to measure on your own machine.

Failures and Retries

When a job throws, its pending database changes are discarded, the attempt is recorded in the history as failed, and the job is scheduled to run again by the retry strategy. By default, it runs up to 10 times, waiting longer after each attempt (2 seconds at first, up to 30 minutes), and a job that runs out of retries is recorded as cancelled. WithRetryStrategy sets another strategy (an IRetryStrategy).

A job that finds it can never succeed, e.g. because the data it works on is gone, throws XactJobCancelException to stop without retries:

public async Task SendInvoiceAsync(int orderId)
{
    var order = await db.Orders.FindAsync(orderId)
        ?? throw new XactJobCancelException($"Order {orderId} doesn't exist anymore");

    // ...
}

The job moves to the history as cancelled right away, with the exception's message as the error. As with any failure, its pending database changes are discarded. The exception is also found when it's wrapped in other exceptions. For a periodic job, only the current run is cancelled: the next one is scheduled as usual.

A job that has nothing to do should just return: it's recorded as completed.

A custom retry strategy gets the job that failed. For the first attempt of a continuation, its ScheduledAt is the reserved time 9999-12-31, so base the retry time on the current time.

Stopping

When the application stops, the workers stop polling and running jobs get the shutdown timeout (WithShutdownTimeout, 10 seconds by default) to finish. Then the cancellation token passed to the jobs is cancelled, and they get 5 more seconds to react. Finally, the leases of the unfinished jobs are cleared so they can run again right away, which takes up to 10 seconds (options.Options.ClearLeaseTimeoutInSeconds). A cancelled job doesn't count as a failed attempt.

With the default settings this fits in the default 30 second shutdown timeout of the .NET host. If you lower that timeout, lower these as well. WithShutdownTimeout(0) cancels the running jobs immediately.

A job that ignores the cancellation token may still be running when its lease is cleared, so it can end up running on another worker at the same time, which the next section covers.

Exactly-Once Database Effects

A lease can expire while a job is still running, for example if a node loses its database connection for a while. Another worker can then run the same job. For the database writes this is safe, as long as the job uses the scoped TDbContext injected into its class and does one of these two things:

  • Doesn't save its changes. The worker saves them when the job finishes, as in the example above.
  • Saves its changes inside a transaction it doesn't commit. The worker commits it when the job finishes:
    public async Task CreateInvoiceAsync(int orderId, CancellationToken cancellationToken)
    {
        await db.Database.BeginTransactionAsync(cancellationToken);
    
        db.Invoices.Add(new Invoice(orderId));
        await db.SaveChangesAsync(cancellationToken);
    
        // ... more work that needs the saved invoice
    }
    

Either way the job's writes, the removal of the job row and the new history row all commit in one transaction. If two workers run the same job, only one of them can commit: the other one's delete affects 0 rows, EF throws a concurrency exception, and its writes roll back. The same happens if the job fails.

This doesn't apply to changes the job saves without a transaction, or commits itself, because they are already permanent when the job finishes. It also doesn't apply to other DbContext instances, or to external side effects like sending emails or calling HTTP APIs. These can happen more than once, so they should be idempotent.

The worker warns when a job saves without a transaction. While a job runs, the worker checks the saves on the scoped TDbContext it created for the job. If the job calls SaveChanges without a transaction, the worker logs a warning that names the job, once per job method, and the save goes ahead. WithSaveOutsideTransaction changes this: with XactJobSaveCheck.Throw, SaveChanges throws instead, so nothing is saved, and the job fails and is retried as after any other exception. XactJobSaveCheck.Ignore turns the check off. Only SaveChanges on that TDbContext is checked: not other DbContext instances, not ExecuteUpdate, ExecuteDelete or raw SQL, which also run right away without a transaction, and not a transaction the job commits itself.

Idempotent External Calls

Calls to other systems, like a payment provider or an email API, are at least once: a job that fails after the call, or whose lease expired, runs the call again. To make them safe, send an idempotency key with the call, and pass it to the job as a method argument:

  • a business key, like $"charge-order-{orderId}",
  • or Guid.NewGuid(): method arguments are evaluated once, when the job is enqueued, so every retry gets the same key.
dbContext.JobEnqueue<PaymentJob>(x => x.ChargeAsync(orderId, Guid.NewGuid(), CancellationToken.None));

A periodic job gets the same arguments on every run, so build its key from the run's cron slot instead, with XactJobContext.OriginallyScheduledAt, e.g. $"daily-report-{context.OriginallyScheduledAt:yyyy-MM-dd}".

The receiving system has to support idempotency keys: it does the work for the first call with a key, and answers the same for the next ones.

Metrics and Logs

The workers publish metrics on the meter XactJobs (System.Diagnostics.Metrics, part of .NET), so XactJobs needs no OpenTelemetry package. Collect them with OpenTelemetry, dotnet-counters or a MeterListener. Nothing is recorded while nothing listens. With the OpenTelemetry packages:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics.AddMeter("XactJobs"));
Metric Type Unit Description
xactjobs.job.attempts counter {attempt} Attempts that ended, by xactjobs.outcome: completed; skipped (an inactive periodic job, or a continuation whose condition isn't met); retried (failed, and scheduled again); failed (out of retries, or cancelled itself); interrupted (stopped by the shutdown, it runs again)
xactjobs.job.duration histogram s How long an attempt took, until its outcome was saved, by xactjobs.outcome
xactjobs.job.start_delay histogram s How long a job waited from the time it was due until it started: a queue falling behind shows here first. A continuation is due when it's claimed
xactjobs.claim.duration histogram s How long a claim took
xactjobs.job.claimed counter {job} Jobs claimed
xactjobs.job.running up-down counter {job} Jobs running now
xactjobs.job.held up-down counter {job} Jobs the workers hold: running, and waiting for a slot

They're all tagged with xactjobs.queue. options.WithJobTagsInMetrics() also tags the attempts, their duration and the start delay with xactjobs.job.type and xactjobs.job.method: one series per job method instead of per queue.

Each attempt runs in a log scope with the job's JobId, JobType, JobMethod, JobQueue and JobAttempt. What the job logs, and what EF logs meanwhile, carries them, with a logging provider that records scopes (the console's IncludeScopes, Serilog, OpenTelemetry's IncludeScopes).

Database Notes

PostgreSQL

The job table is a high-churn queue: every job inserts, updates and deletes a row. Aggressive autovacuum settings for this table are recommended, for example:

ALTER TABLE xact_jobs.job SET (
  autovacuum_vacuum_scale_factor = 0,
  autovacuum_vacuum_threshold = 1000,
  autovacuum_vacuum_cost_limit = 2000
);

Long-running transactions anywhere in the cluster hold back the cleanup of dead rows, not just the ones using this table. Dead rows pile up meanwhile, which slows down fetching jobs.

MySQL

XactJobs needs MySQL 8.0 or later, because claiming jobs uses SKIP LOCKED and FOR UPDATE OF. MariaDB isn't supported: it doesn't support FOR UPDATE OF, and only supports SKIP LOCKED from 10.6.

The workers claim jobs at READ COMMITTED, not at InnoDB's default REPEATABLE READ. At REPEATABLE READ, a claim would keep the running jobs it scans locked, and the gaps between rows too, so finished jobs couldn't be deleted, and new jobs (JobEnqueue and SaveChanges) couldn't be saved until the claim commits. Only the claims use it: your own transactions, and the jobs, keep their isolation level.

The provider depends on the version of .NET:

.NET MySQL provider
.NET 8 Pomelo.EntityFrameworkCore.MySql
.NET 9 Pomelo.EntityFrameworkCore.MySql or MySql.EntityFrameworkCore
.NET 10 MySql.EntityFrameworkCore

Pomelo doesn't support EF Core 10 yet, and on .NET 8 the official provider only supports EF Core 8. These combinations build, but fail at runtime.

With the official provider (MySql.EntityFrameworkCore), SaveChanges doesn't start a transaction on its own. If a statement fails, the ones before it stay committed. This can break the main promise of XactJobs in your own code: for example, a job can be saved without the business data it belongs to.

To make your saves all-or-nothing, either use a transaction, as in Transactions, or set this on your DbContext:

public class UserDbContext : DbContext
{
    public UserDbContext(DbContextOptions<UserDbContext> options) : base(options)
    {
        Database.AutoTransactionBehavior = AutoTransactionBehavior.Always;
    }
}

The workers already do this for their own saves, so running jobs are not affected. Pomelo starts a transaction when a save has more than one statement.

Oracle

XactJobs needs Oracle 12c or later. A claim is one PL/SQL block, which returns the claimed jobs with DBMS_SQL.RETURN_RESULT.

Jobs are claimed oldest first. The block locks the due jobs with FOR UPDATE SKIP LOCKED, fetches only as many as the worker has free slots, and leases them. Oracle locks a row only when it's fetched, so workers that poll at once each get different jobs from a backlog. ROWNUM can't be used to limit the rows, because it's applied before locked rows are skipped: all the workers would try the same first rows, and all but one would get none.

The claims read the table's primary key in order (with an index hint), so a claim doesn't sort the whole backlog, even if Oracle cached a plan from when the table was nearly empty.

License

XactJobs is licensed under the MIT License: see the LICENSE file. Versions before 0.3.0 were licensed under the LGPL-3.0-or-later.

Included Third-Party Code

License Texts

  • The MIT License text is in the LICENSE file.
  • The Apache License 2.0 text is in the LICENSE-ASP.NET.txt file.
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 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 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

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
0.4.0 0 11/8/2026
0.3.2 0 11/8/2026
0.3.1 0 11/8/2026
0.3.0 37 11/8/2026
0.2.0 50 11/5/2026