Skip to main content

Jobs & Workflows

Excalibur.Jobs provides background job scheduling, multi-step workflows, and distributed job coordination — all powered by Quartz.NET under the hood.

Before You Start​

Packages​

PackagePurpose
Excalibur.JobsJob scheduling, workflows, coordination, distributed locks, built-in jobs
Excalibur.Jobs.AbstractionsIBackgroundJob, IBackgroundJob<TContext>, IJobConfig interfaces

Quick Start​

1. Create a Job​

Implement IBackgroundJob for simple jobs, or IBackgroundJob<TContext> when you need typed input:

using Excalibur.Jobs;

public class CleanupJob : IBackgroundJob
{
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
// Perform cleanup work
}
}

2. Register and Schedule​

Use IExcaliburBuilder.AddJobs(...) to set up the complete job hosting environment with Quartz.NET:

using System.Reflection;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddExcalibur(excalibur => excalibur.AddJobs(
configureJobs: jobs =>
{
// Cron-scheduled job (runs at midnight daily)
jobs.AddJob<CleanupJob>("0 0 0 * * ?");

// Recurring job (runs every 5 minutes)
jobs.AddRecurringJob<HealthPingJob>(TimeSpan.FromMinutes(5));

// One-time job (runs immediately on startup)
jobs.AddOneTimeJob<MigrationJob>();

// Delayed job (runs 30 seconds after startup)
jobs.AddDelayedJob<WarmupJob>(TimeSpan.FromSeconds(30));

// Conditional job (only in production)
jobs.AddJobIf(builder.Environment.IsProduction(), j =>
j.AddJob<MetricsAggregationJob>("0 */15 * * * ?"));
},
typeof(Program).Assembly));

var app = builder.Build();
app.Run();

That's it. IExcaliburBuilder.AddJobs(...) registers Quartz.NET, the hosted service, health checks, and all your jobs in one call.

3. Run It​

Your job runs on the configured schedule. Quartz.NET handles scheduling and thread management. By default jobs and triggers are held in an in-memory store (RAMJobStore) — they are re-seeded on every startup and nothing survives a restart. To persist schedule state and coordinate across multiple instances, configure a persistent, clustered job store.

Registration Options​

There are several ways to register jobs depending on your needs:

Unified Entry Point (Recommended)​

services.AddExcalibur(excalibur => excalibur.AddJobs(
configureQuartz: q =>
{
// Low-level Quartz configuration (optional)
// Quartz 4 uses dependency injection by default.
},
configureJobs: jobs =>
{
jobs.AddJob<CleanupJob>("0 0 0 * * ?");
jobs.AddRecurringJob<PingJob>(TimeSpan.FromMinutes(1));
},
typeof(Program).Assembly));

Individual Registration​

If you need more control, register jobs individually:

// Cron-scheduled
services.AddBackgroundJob<CleanupJob>("0 0 0 * * ?");

// With typed context
services.AddBackgroundJob<ReportJob, ReportContext>(
"0 0 6 * * ?",
new ReportContext { Format = "PDF", StartDate = DateOnly.FromDateTime(DateTime.Today) });

// Fixed interval
services.AddRecurringJob<PingJob>(TimeSpan.FromMinutes(5));

Job Configurator Fluent API​

The IJobConfigurator supports chaining:

MethodDescription
AddJob<T>(cron)Schedule with a cron expression
AddJob<T, TContext>(cron, context)Schedule with typed context
AddRecurringJob<T>(interval)Run at fixed intervals
AddOneTimeJob<T>()Run once on startup
AddDelayedJob<T>(delay)Run once after a delay
AddJobIf(condition, configure)Conditionally add jobs
AddJobInstances<T>(configs...)Multiple instances with different schedules

Jobs with Typed Context​

For jobs that need input data, implement IBackgroundJob<TContext>:

using Excalibur.Jobs;

public class ReportJob : IBackgroundJob<ReportContext>
{
public async Task ExecuteAsync(
ReportContext context,
CancellationToken cancellationToken)
{
// Generate report using context.Format, context.StartDate, etc.
}
}

public class ReportContext
{
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public string Format { get; set; } = "PDF";
}

Register with context data:

services.AddBackgroundJob<ReportJob, ReportContext>(
"0 0 6 * * ?", // 6 AM daily
new ReportContext { Format = "PDF", StartDate = DateOnly.FromDateTime(DateTime.Today) });

Built-In Jobs​

Excalibur ships with several ready-to-use jobs. Each implements the Quartz IJob interface with [DisallowConcurrentExecution] and provides static configuration methods for scheduling and health checks.

Two ways to schedule — pick by job type

There are two distinct scheduling channels on AddJobs(...), and they are not interchangeable:

  • configureQuartz: q => … — for the built-in jobs (OutboxJob, CdcJob, DataProcessingJob). They are Quartz-native IJob types, so you schedule them by calling their static XxxJob.ConfigureJob(q, configuration) inside configureQuartz.
  • configureJobs: jobs => … — for your own jobs that implement IBackgroundJob / IBackgroundJob<TContext>, using the fluent jobs.AddJob<T>(cron) / AddRecurringJob<T>(…) API.

You can use either, both, or neither. The built-in jobs do not use configureJobs — that channel is only for the IBackgroundJob abstraction.

OutboxJob​

Processes the outbox table, publishing pending messages to transports. Use this for reliable at-least-once message delivery.

ConfigureJob only schedules the job — you must also register the outbox subsystem so OutboxJob's IOutboxDispatcher dependency can be resolved, otherwise the job fails to activate at trigger time.

builder.Services.AddExcalibur(excalibur => excalibur
// Register the outbox subsystem — provides the IOutboxDispatcher OutboxJob needs.
.AddOutbox(o => o.UseSqlServer(sql => sql.ConnectionString(connectionString)))
.AddJobs(configureQuartz: q =>
{
// Reads the "Jobs:OutboxJob" section from the root configuration.
OutboxJob.ConfigureJob(q, builder.Configuration);
}));

// Add its health check (signature: IHealthChecksBuilder, IConfiguration).
OutboxJob.ConfigureHealthChecks(
builder.Services.AddHealthChecks(),
builder.Configuration);
appsettings.json
{
"Jobs": {
"OutboxJob": {
"JobName": "outbox-processor",
"CronSchedule": "0/10 * * * * ?",
"DegradedThreshold": "00:05:00",
"UnhealthyThreshold": "00:10:00"
}
}
}

CdcJob​

Runs change data capture processing to detect and publish database changes.

Register the services CdcJob depends on (AddSqlServerCdcJob binds the Jobs:CdcJob section and registers the change-event processor factory + SQL Server policy factory), then schedule it. ConfigureJob only schedules — it does not register dependencies.

// Service registration:
builder.Services.AddExcaliburSqlServices();
builder.Services.AddSqlServerCdcJob(builder.Configuration);

// Scheduling, inside AddJobs(configureQuartz: q => ...):
CdcJob.ConfigureJob(q, builder.Configuration);
CdcJob.ConfigureHealthChecks(builder.Services.AddHealthChecks(), builder.Configuration);

DataProcessingJob​

Generic data processing pipeline job for batch operations.

Register the data-processing services (which provide the IDataOrchestrationManager the job needs) with AddDataProcessing, then schedule the job.

// Service registration:
builder.Services.AddDataProcessing(dp => dp
.ConnectionFactory(() => new SqlConnection(connectionString))
.BindConfiguration("DataProcessing")
.AddProcessor<MyProcessor>());

// Scheduling, inside AddJobs(configureQuartz: q => ...):
DataProcessingJob.ConfigureJob(q, builder.Configuration);
DataProcessingJob.ConfigureHealthChecks(builder.Services.AddHealthChecks(), builder.Configuration);

HealthCheckJob​

Periodic health check execution job. Runs all registered IHealthCheck implementations on a schedule.

// Register as a simple background job
services.AddBackgroundJob<HealthCheckJob>("0 */5 * * * ?"); // Every 5 minutes

OutboxProcessorJob​

A simpler alternative to OutboxJob — implements IBackgroundJob instead of Quartz IJob. Use this when you want to process the outbox via the AddBackgroundJob registration pattern rather than Quartz-native configuration.

services.AddRecurringJob<OutboxProcessorJob>(TimeSpan.FromSeconds(10));

Persistent & Clustered Job Store​

By default, Quartz.NET uses an in-memory store (RAMJobStore): schedule state is lost on restart, and every instance runs its own independent copy of every trigger. For production — especially multi-instance deployments — configure a persistent ADO store, optionally with clustering.

Excalibur does not wrap or hide Quartz's store configuration. AddJobs(...) forwards the configureQuartz delegate straight to Quartz's IQuartzBuilder, so you use Quartz's own UsePersistentStore API directly:

builder.Services.AddExcalibur(excalibur => excalibur.AddJobs(
configureQuartz: q =>
{
q.UsePersistentStore(store =>
{
store.UseProperties = true; // store job data as strings (AOT/serialization-friendly)
store.UseClustering(); // enable the clustered scheduler
store.UseSqlServer(Microsoft.Data.SqlClient.SqlClientFactory.Instance, connectionString);
// Source-generated System.Text.Json serialization is built in.
});

// Built-in jobs are scheduled the same way regardless of store.
OutboxJob.ConfigureJob(q, builder.Configuration);
},
typeof(Program).Assembly));

Add the matching Quartz provider package (e.g. Quartz includes the ADO providers; you supply the ADO.NET driver such as Microsoft.Data.SqlClient) and create the Quartz database tables for your provider.

Clustering and the built-in jobs​

OutboxJob, CdcJob, and DataProcessingJob are all annotated with [DisallowConcurrentExecution]. This attribute only prevents overlapping execution across instances when backed by a persistent, clustered store. With the default in-memory store the guarantee is per-process only — every instance will run the job independently.

If you run these jobs on more than one instance, you therefore want either:

  • a persistent clustered store (above), so Quartz itself ensures a single instance runs each fire, or
  • the distributed coordination primitives (IJobLockProvider / IJobCoordinator) for an explicit application-level lock.
Disabling jobs with a persistent store

With a persistent store, Disabled: true alone is not enough to stop an already-persisted job — see Disabling a Job.

Job Configuration​

All configurable jobs use JobConfig (or a subclass) for their settings:

PropertyTypeDefaultDescription
JobNamestring""Unique name for the job
JobGroupstring"Default"Quartz job group
CronSchedulestring""Cron expression for scheduling
DisabledboolfalseWhen true, the job's trigger is not registered, so it never fires. See Disabling a Job
DegradedThresholdTimeSpan5 minutesTime without heartbeat before health is degraded
UnhealthyThresholdTimeSpan10 minutesTime without heartbeat before health is unhealthy

Disabling a Job​

Set Disabled: true to stop a built-in job (OutboxJob, CdcJob, DataProcessingJob) from running. Each job's ConfigureJob honors the flag at scheduling time — when disabled, the job and its trigger are never registered with the scheduler, so no trigger fires.

appsettings.json
{
"Jobs": {
"CdcJob": {
"JobName": "cdc-processor",
"CronSchedule": "0/30 * * * * ?",
"Disabled": true
}
}
}
Persistent job stores

The schedule-time gate is sufficient for the default in-memory job store. With a persistent job store, a job that was already scheduled survives across restarts — skipping registration does not delete it, so a job persisted while enabled keeps firing after you later set Disabled: true. To disable an already-persisted job, use the runtime watcher (it pauses the job through the scheduler, and the paused state is persisted) or delete the job from the store.

Runtime Configuration Changes​

Jobs that implement IConfigurableJob<TConfig> can be monitored for configuration changes at runtime. When the configuration changes (e.g., Disabled toggled), the job is automatically paused or resumed via the scheduler — no restart required. This is also the recommended way to honor Disabled when using a persistent job store, because pausing (unlike skipping registration) updates the persisted trigger state:

services.AddJobWatcher<OutboxJob, OutboxJobOptions>(
builder.Configuration.GetSection("Jobs:OutboxJob"));

Health Checks​

Each job tracks its health via JobHeartbeatTracker, a singleton that records when a job last executed successfully. The JobHealthCheck compares the last heartbeat against the configured thresholds:

  • Healthy — Last heartbeat within DegradedThreshold (default: 5 minutes)
  • Degraded — Last heartbeat between DegradedThreshold and UnhealthyThreshold
  • Unhealthy — No heartbeat recorded, or last heartbeat exceeds UnhealthyThreshold (default: 10 minutes)

Health checks are registered per-job via each job's ConfigureHealthChecks static method. IExcaliburBuilder.AddJobs(...) automatically registers the JobHeartbeatTracker singleton.

Distributed Coordination​

For multi-instance deployments, IJobCoordinator prevents duplicate execution and distributes work across instances. It composes three focused interfaces:

InterfaceMethodsPurpose
IJobLockProviderTryAcquireLockAsyncDistributed exclusive locks
IJobRegistryRegisterInstanceAsync, UnregisterInstanceAsync, GetActiveInstancesAsyncInstance registration and discovery
IJobDistributorDistributeJobAsync, ReportJobCompletionAsyncWork distribution across instances

Setup​

// Redis-backed coordination
services.AddJobCoordinationRedis("localhost:6379");

// Or with an existing connection
services.AddJobCoordinationRedis(existingConnectionMultiplexer);

// Or with a custom implementation
services.AddJobCoordination<MyCustomCoordinator>();

Distributed Locks​

Use IJobLockProvider (or IJobCoordinator) to acquire exclusive locks that prevent concurrent execution of the same job across instances:

using Excalibur.Jobs.Coordination;

public class ExclusiveImportJob : IBackgroundJob
{
private readonly IJobLockProvider _locks;

public ExclusiveImportJob(IJobLockProvider locks)
{
_locks = locks;
}

public async Task ExecuteAsync(CancellationToken cancellationToken)
{
await using var jobLock = await _locks.TryAcquireLockAsync(
"exclusive-import",
TimeSpan.FromMinutes(10),
cancellationToken);

if (jobLock is null)
{
return; // Another instance holds the lock
}

// Lock acquired — safe to proceed
await DoExclusiveWork(cancellationToken);

// Extend if the work takes longer than expected
await jobLock.ExtendAsync(TimeSpan.FromMinutes(5), cancellationToken);

// Lock released automatically via IAsyncDisposable
}
}

The IDistributedJobLock provides:

MemberPurpose
JobKeyThe job identifier this lock covers
InstanceIdThe instance that holds the lock
AcquiredAtWhen the lock was acquired
ExpiresAtWhen the lock will expire
IsValidWhether the lock is still active
ExtendAsync(duration, ct)Extend the lock's TTL
ReleaseAsync(ct)Explicitly release the lock
DisposeAsync()Auto-releases on disposal

Instance Registration​

Use IJobRegistry to register worker instances so the coordinator knows who's available:

using Excalibur.Jobs.Coordination;

public class WorkerService : BackgroundService
{
private readonly IJobRegistry _registry;

public WorkerService(IJobRegistry registry)
{
_registry = registry;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var instanceId = Environment.MachineName;

await _registry.RegisterInstanceAsync(
instanceId,
new JobInstanceInfo(instanceId, Environment.MachineName,
new JobInstanceCapabilities(
maxConcurrentJobs: 4,
supportedJobTypes: ["reports", "notifications"])),
stoppingToken);

try
{
// Do work...
await Task.Delay(Timeout.Infinite, stoppingToken);
}
finally
{
await _registry.UnregisterInstanceAsync(instanceId, CancellationToken.None);
}
}
}

Work Distribution​

Use IJobDistributor to distribute jobs across registered instances:

// Distribute a job to an available instance
var assignedInstance = await _distributor.DistributeJobAsync(
"daily-report",
new { ReportDate = DateTime.Today },
cancellationToken);

// Report completion when done
await _distributor.ReportJobCompletionAsync(
"daily-report",
instanceId,
success: true,
result: new { RowsProcessed = 1500 },
cancellationToken);
Use ILeaderElection for leader election

IJobCoordinator handles job locking, instance registration, and work distribution. For leader election (electing a single coordinator instance), use ILeaderElection from the Leader Election package instead.

Workflows​

Preview

WorkflowContext is currently a preview implementation using in-memory state only. It does not provide durable scheduling, real step dispatch, or persistent checkpoints. A production workflow orchestration implementation is planned.

Workflows chain multiple steps with typed input and output:

using Excalibur.Jobs.Workflows;

public class OrderProcessingWorkflow : IWorkflow<OrderInput, OrderOutput>
{
public async Task<WorkflowResult<OrderOutput>> ExecuteAsync(
OrderInput input,
IWorkflowContext context,
CancellationToken cancellationToken)
{
// Step 1: Validate order
var validated = await ValidateOrder(input, cancellationToken);

// Step 2: Process payment
var payment = await ProcessPayment(validated, cancellationToken);

// Step 3: Fulfill order
var fulfillment = await FulfillOrder(payment, cancellationToken);

return WorkflowResult<OrderOutput>.Success(new OrderOutput
{
OrderId = input.OrderId,
TrackingNumber = fulfillment.TrackingNumber
});
}
}

public record OrderInput(Guid OrderId, decimal Amount);
public record OrderOutput
{
public Guid OrderId { get; init; }
public string TrackingNumber { get; init; } = string.Empty;
}

Register Workflows​

services.AddWorkflows(); // Register core workflow services
services.AddWorkflow<OrderProcessingWorkflow, OrderInput, OrderOutput>();

This registers:

  • The workflow implementation
  • IWorkflow<OrderInput, OrderOutput> for DI resolution
  • WorkflowJob<OrderProcessingWorkflow, OrderInput, OrderOutput> for executing the workflow as a background job

See Also​

Upgrading an existing Quartz deployment​

The Quartz integration packages target Quartz 4.3.0 together. Upgrade Excalibur.Jobs, Excalibur.Jobs.Cdc, Excalibur.Jobs.DataProcessing, and Excalibur.Hosting.Jobs as a unit. The hosting callback now accepts IQuartzBuilder; native Quartz jobs implement ValueTask Execute(IJobExecutionContext, CancellationToken). Excalibur's existing one-argument execution methods forward the context token for direct callers.

Apply the version-specific SQL migrations before starting the upgraded workers. Fresh schema provisioning does not migrate existing tables. Preserve scheduler names, table prefixes, job keys, trigger keys, and persisted context. Test against a restored copy of an existing database and validate pending triggers, restart, cancellation, and recovery. See the Quartz migration guide and schema changes.

For Native AOT, register jobs using typed APIs, provide SqlClientFactory.Instance for SQL Server, and pass source-generated JsonTypeInfo<TContext> to context-bearing jobs:

jobs.AddJob<ReportJob, ReportContext>(
"0 0 * * * ?", context, ReportJsonContext.Default.ReportContext, "daily-report");

Register the same metadata on every startup. Pass a new contextVersion when changing converters or naming policy, and explicitly migrate existing persisted payloads. Missing codec versions fail before invoking the job. Legacy reflection payloads retain their original decoding policy and must be migrated before Native AOT execution. Metadata is scoped to the job identity; context JSON, its type identity, and its serialization mode/version are persisted. The legacy overload uses reflection serialization in managed applications and rejects reflection-disabled registration with a message directing callers to the metadata overload. Assembly-scanning hosting overloads remain unsuitable for Native AOT; use explicit registration.

Scheduled Outbox drains​

Outbox jobs invoke your registered IOutboxDispatcher, preserving custom implementations and decorators. Register the dispatcher as well as the job; missing registration fails the firing rather than reporting a healthy no-op.

The framework MessageOutbox runs one cycle per call, matching the dispatcher interface. With standard DI registration, each cycle owns a fresh processor scope and releases it before success is reported. Disposing the dispatcher cancels and joins its active cycles. The original constructor accepting an existing processor remains available; use a stable dispatcher identity and sequential calls with that constructor. Use DisposeAsync for active cycles or an async-only supplied processor.

This corrects the previous concrete implementation's continuous loop and swallowed failures. Schedule repeated cycles when continuous operation is required. SignalNewMessage() is retained as a compatibility no-op; saving or signaling alone will not schedule a cycle.

PerRunTotal must be positive. It bounds reserved rows per firing, including repeated identities; it is not a wall-clock timeout. Stores and handlers must cooperate with cancellation. An individual processor supports sequential drains, rejects overlapping runs, and keeps the same dispatcher identity throughout its lifetime.