Jobs Feature Documentation¶
Schedule durable background work through devkit-owned jobs, triggers, batches, leases, history, and source-level integrations.
Overview¶
Application.Jobs is the devkit-native scheduling feature for recurring, delayed, one-time, manual, chained, batched, calendar-driven, and event-triggered work.
It is a standalone feature with its own authoring model, runtime, persistence abstractions, operational APIs, and HTTP surface.
The feature is split across three projects:
Application.Jobs: authoring model, runtime services, query contracts, maintenance APIs, in-memory persistence, and test harnessesInfrastructure.EntityFramework: durable Entity Framework Jobs providerPresentation.Web.Jobs: operational HTTP endpoints for dashboards, support tooling, and controlled runtime mutations
Key characteristics:
- code-first job and trigger registration through
AddJobScheduler(...) - durable occurrences and execution history through provider-neutral stores
- inline dispatch APIs for application code and background execution for hosted scheduling
- batch and chaining support without turning the scheduler into a workflow engine
- optional outbound and event-trigger adapters for Requester, Notifier, Messaging, Queueing, Pipelines, and Orchestrations
- built-in maintenance jobs for archive, purge, and repair operations
Glossary¶
| Term | Meaning |
|---|---|
| Job scheduler | The runtime registered by AddJobScheduler(...); it owns registration resolution, dispatch, trigger materialization, execution, persistence, querying, and maintenance. |
| Job definition | The code-first registration that names a job, its data contract, trigger definitions, retry behavior, concurrency limits, properties, and chaining rules. |
| Job name | The stable public identifier for a job definition. It is used for dispatch, persisted occurrences, filters, metrics, endpoints, and logs. |
| Inline job | A job definition backed by a registration-time delegate instead of a dedicated IJob class. It still uses the normal occurrence, lease, retry, and history pipeline. |
| Trigger | A named schedule or activation mode that materializes occurrences for a job. |
| Trigger name | The stable identifier for a trigger within one job. Trigger names must be unique per job. |
| Trigger type | The trigger category, such as manual, cron, calendar, one-time, delayed, startup-delay, event, or custom. |
| Manual trigger | A trigger that does not materialize work on its own and is used by explicit dispatch calls. |
| Cron trigger | A trigger based on a cron expression and optional time zone. |
| Calendar trigger | A trigger based on business-calendar rules such as dates, weekdays, exclusions, or month-end behavior. |
| One-time trigger | A trigger that creates work for one configured instant. |
| Delayed trigger | A trigger that creates work after a configured delay. |
| Startup-delay trigger | A trigger evaluated after a scheduler instance starts and the configured startup delay elapses. |
| Occurrence | One durable unit of work created by dispatch or trigger materialization. An occurrence may have zero or more execution attempts. |
| Due occurrence | An occurrence whose due time has arrived and can be acquired by an eligible scheduler instance. |
| Missed occurrence | An occurrence that should have been pending or executed while the scheduler was stopped, unavailable, or unable to acquire work. |
| Manual dispatch | An explicit application or operator request to create an occurrence from a manual trigger. |
| Accepted asynchronous dispatch | DispatchAsync(...) accepts and persists an occurrence, then returns without running it inline. Background execution later acquires and runs the occurrence. |
| Inline dispatch | DispatchAndWaitAsync(...) creates an occurrence and waits for the current execution attempt to reach a terminal result. |
| Background execution | Hosted scheduler processing that pending due triggers, acquires leases, and runs eligible occurrences through the worker pool. |
| Execution | One attempt to run an occurrence. Retries create additional executions for the same occurrence. |
| Execution history | Append-only lifecycle records for occurrences, executions, retries, leases, operator actions, batch operations, and diagnostics. |
| Job execution context | The IJobExecutionContext passed to a job execution. It exposes identity, typed data, properties, items, previous execution snapshots, messages, and cancellation. |
| Previous execution | The immediately preceding execution attempt for the same occurrence, mainly useful during retries. |
| Previous successful execution | The latest successful execution for the same job and trigger before the current occurrence, used for delta-style processing. |
| Scheduler instance | One running scheduler host identified by InstanceId(...); target lists created with TargetInstances(...) refer to these ids. |
| Worker pool | The bounded execution slots inside a scheduler instance that acquire leases and run occurrences concurrently. |
| Lease | The provider-backed ownership record that allows one scheduler instance to execute an occurrence safely. |
| Runtime state | Persisted enable, disable, pause, resume, and materialization overlays applied to jobs or triggers without changing source code. |
| Data | The durable typed payload supplied by a trigger or dispatch call and exposed through IJobExecutionContext<TData>.Data. |
| Properties | Immutable string key/value values that travel with dispatch, occurrences, and integration flows for filtering, diagnostics, and runtime decisions. |
| Items | The mutable context.Items dictionary used only during the active execution attempt. Items are not the durable payload. |
| Correlation id | A stable id used to connect dispatches, events, occurrences, executions, logs, and telemetry for one business operation. |
| Idempotency key | A stable key used to avoid creating duplicate occurrences for the same logical operation or accepted event. |
| Batch | A durable grouping of multiple occurrences for parallel fan-out, monitoring, bulk control, and selective retry. |
| Batch child occurrence | An occurrence attached to a batch and counted in the batch's pending, processing, succeeded, failed, cancelled, and finished totals. |
| Dependency | An occurrence-to-occurrence prerequisite created by job chaining. The dependent occurrence waits until its prerequisite reaches the required terminal state. |
| Chaining | The .Then(...) authoring model that creates dependent occurrences after a source occurrence completes successfully. |
| Retry policy | The rules that decide whether a failed execution should create another attempt and when that attempt becomes due. |
| Concurrency limit | A job-level execution cap that limits how many occurrences for the same job may run at the same time. |
| Behavior | A pipeline component that wraps a concrete execution attempt for cross-cutting concerns such as metrics, module scope, validation, or test fault injection. |
| Maintenance operation | A provider-neutral purge, archive, repair, lease recovery, or runtime-state cleanup action over retained scheduler data. |
| Maintenance service | IJobSchedulerMaintenanceService, the service that exposes provider-neutral purge and repair operations. |
| Durable provider | A store provider that persists runtime state, occurrences, executions, history, batches, dependencies, accepted events, and leases across restarts. |
| In-memory provider | The default lightweight provider for tests, local development, and transient workloads. It does not provide recovery across process restarts. |
| Entity Framework provider | The durable provider that stores Jobs data in an application DbContext through IJobsContext and annotated Jobs entities. |
IJobsContext |
The Entity Framework capability interface implemented by an application DbContext to host Jobs persistence sets. |
| Accepted event | A persisted event captured from Notifier, Messaging, Queueing, or a custom source and later materialized into a normal occurrence. |
| Event trigger | A trigger that materializes occurrences from accepted events instead of time-based schedules or manual dispatch. |
| Custom trigger | A trigger backed by an application-provided trigger provider when cron, calendar, manual, one-time, delayed, startup, and event triggers are not enough. |
| Query service | The read side exposed by IJobSchedulerQueryService for dashboards, support tooling, and operational endpoints. |
| Operational endpoints | The HTTP surface from Presentation.Web.Jobs for dashboards, support tooling, runtime control, batch operations, and maintenance actions. |
| Test harness | The Jobs testing helpers that execute jobs or scheduler flows with in-memory persistence and controlled runtime state. |
Core Service Surface¶
At runtime, Jobs exposes three service layers with distinct responsibilities:
IJobSchedulerService: dispatch, pause/resume, enable/disable, occurrence control, lease release, and batch operationsIJobSchedulerQueryService: dashboard-ready read models for jobs, triggers, occurrences, retries, executions, batches, leases, servers, metrics, and dashboard summariesIJobSchedulerMaintenanceService: provider-neutral purge and repair operations over retained scheduler state
These services all use the devkit Result pattern for runtime or query outcomes where business/runtime validation can fail without throwing exceptions.
Basic Setup¶
Register jobs and triggers in code during application startup:
builder.Services.AddJobScheduler()
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *")));
Jobs includes small cron convenience helpers for code-first registrations:
builder.Services.AddJobScheduler()
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.AddTrigger("nightly", trigger => trigger.Cron(CronExpressions.DailyAt2AM))
.AddTrigger("recheck", trigger => trigger.Cron(
new CronExpressionBuilder()
.EveryMinutes(30)
.Build())));
Cron helper notes:
CronExpressionscontains common Jobs cron constants such asEvery30Minutes,DailyAt2AM, andWeeklyOnMondayAtMidnight.CronExpressionBuilderbuilds standard five-field Jobs cron expressions by default.- Seconds-based builder methods such as
EverySeconds(...)emit the supported six-field Jobs format. IJobCronEngineis registered byAddJobScheduler()and can be injected into validators or services that need cron validation or occurrence calculation.
For lightweight function-oriented logic, use the inline delegate overload. Inline jobs are still normal job definitions: they create occurrences, hydrate IJobExecutionContext, honor triggers and runtime state, and write execution history through the same pipeline as class-based jobs.
builder.Services.AddScoped<CustomerCleanupProcessor>();
builder.Services.AddJobScheduler()
.WithJob("cleanup-customers-inline", job => job
.Description("Runs the customer cleanup processor inline for the caller.")
.Execute((context, serviceProvider, cancellationToken) =>
serviceProvider.GetRequiredService<CustomerCleanupProcessor>()
.RunAsync(cancellationToken))
.AddTrigger("manual", trigger => trigger.Manual()));
Manual dispatch uses the scheduler service directly:
var scheduler = app.Services.GetRequiredService<IJobSchedulerService>();
var result = await scheduler.DispatchAndWaitAsync<CleanupCustomersJob>(
cancellationToken: cancellationToken);
if (result.IsSuccess)
{
Console.WriteLine(result.Value.Status);
}
Named inline jobs can also be dispatched and awaited directly by job name:
var result = await scheduler.DispatchAndWaitAsync(
"cleanup-customers-inline",
cancellationToken: cancellationToken);
That DispatchAndWaitAsync(...) path is the feature's inline execution mode: the scheduler still creates the occurrence and execution records, but the caller waits for the terminal result of the current attempt.
Composing Registrations Across Modules¶
You can call AddJobScheduler() multiple times from different feature modules or startup slices. Each call reuses the same scheduler singletons and the same JobRegistrationStore, so the registrations compose into one scheduler runtime.
services.AddJobScheduler()
.WithBackgroundExecution(options => options.EnableBackgroundExecution = true);
services.AddJobScheduler()
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *")));
services.AddJobScheduler()
.WithBuiltInMaintenanceJobs();
This pattern is useful when a host, a shared module, and an integration feature all need to contribute jobs without centralizing every registration in one file.
Scheduler Instance And Worker Pool¶
Hosted background execution can be aligned explicitly to a stable scheduler instance id and tuned through the top-level builder.
builder.Services.AddJobScheduler()
.InstanceId(context => $"{context.Environment.ApplicationName}:{context.Environment.MachineName}")
.StartupDelay(TimeSpan.FromSeconds(10))
.WorkerPool(pool => pool
.MaxConcurrency(4)
.BatchSize(32)
.PollInterval(TimeSpan.FromSeconds(5)))
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *")));
Use InstanceId(...) when several hosts share the same jobs store and you need stable scheduler instance targeting, diagnostics, or lease ownership labels across restarts.
Exception Handling¶
Unhandled scheduler failures can be observed centrally through WithExceptionHandler<THandler>().
builder.Services.AddJobScheduler()
.WithExceptionHandler<OpsAlertingJobExceptionHandler>()
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *")));
Implement IJobSchedulerExceptionHandler when you need last-chance telemetry, alerting, or operational diagnostics for exceptions that escape the normal scheduler pipeline:
public sealed class OpsAlertingJobExceptionHandler : IJobSchedulerExceptionHandler
{
public Task HandleAsync(JobSchedulerExceptionContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine($"[{context.Source}] {context.SchedulerInstanceId}: {context.Exception.Message}");
return Task.CompletedTask;
}
}
Handler notes:
- handlers receive the scheduler instance id, exception source, exception, and when available the resolved job definition, trigger, occurrence id, and execution id
- execution failures raised from a concrete job attempt use
JobSchedulerExceptionSource.Execution - failures raised by the hosted scheduler loop use
JobSchedulerExceptionSource.BackgroundService - normal
Resultfailures, validation errors, retries, and other handled scheduler outcomes do not flow through this last-chance exception surface
Scheduler Instance Targeting¶
Jobs can constrain execution to specific scheduler instances. Set a default target list on the job or override it on an individual trigger.
builder.Services.AddJobScheduler()
.InstanceId("node-a")
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.TargetInstances("node-a", "node-b") // job level
.AddTrigger("manual", trigger => trigger.Manual())
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *").TargetInstances("node-b"))); // trigger level
Targeting rules:
- job-level
TargetInstances(...)applies to all triggers that do not declare their own targets - trigger-level
TargetInstances(...)overrides the job-level targets for that trigger only - background sweeps due work as usual, but only eligible scheduler instances pick targeted occurrences for execution
- direct inline dispatch fails clearly when the current scheduler instance is not one of the trigger's effective targets
Appsettings can override the same targeting properties for code-registered jobs:
{
"Jobs": {
"cleanup-customers": {
"TargetInstances": ["node-a", "node-b"],
"Triggers": {
"nightly": {
"TargetInstances": ["node-b"]
}
}
}
}
}
Durable Persistence And Endpoints¶
The default registration uses the in-memory provider. To make occurrences, leases, batches, history, and runtime state durable, wire the Entity Framework provider and optionally add the operational endpoint package.
builder.Services.AddJobScheduler()
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *")))
.WithEntityFramework<AppDbContext>()
.AddEndpoints(options => options
.RequireAuthorization());
The default Jobs endpoint group is /_bdk/api/jobs.
Entity Framework DbContext Setup¶
To persist occurrences, executions, history, leases, batches, and runtime state in your application database, the host DbContext only needs to implement IJobsContext and expose the Jobs sets. The Jobs entities carry their EF mapping through attributes and conventions, so no Jobs-specific ModelBuilder call is required.
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
: ModuleDbContextBase(options), IJobsContext
{
public DbSet<Order> Orders { get; set; }
public DbSet<JobRuntimeStateEntity> JobRuntimeStates { get; set; }
public DbSet<JobTriggerRuntimeStateEntity> JobTriggerRuntimeStates { get; set; }
public DbSet<JobOccurrenceEntity> JobOccurrences { get; set; }
public DbSet<JobOccurrenceDependencyEntity> JobOccurrenceDependencies { get; set; }
public DbSet<JobBatchEntity> JobBatches { get; set; }
public DbSet<JobBatchOccurrenceEntity> JobBatchOccurrences { get; set; }
public DbSet<JobExecutionEntity> JobExecutions { get; set; }
public DbSet<JobExecutionHistoryEntity> JobExecutionHistory { get; set; }
public DbSet<JobBatchHistoryEntity> JobBatchHistory { get; set; }
public DbSet<JobAcceptedEventEntity> JobAcceptedEvents { get; set; }
public DbSet<JobLeaseEntity> JobLeases { get; set; }
}
Register the DbContext first, then switch the Jobs feature to the EF-backed provider:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddJobScheduler()
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customer records.")
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *")))
.WithEntityFramework<AppDbContext>();
Setup notes:
- The same application DbContext can own both business tables and Jobs tables.
- Provider selection stays on
AddDbContext(...);WithEntityFramework<TContext>()only tells Jobs which registered context implementsIJobsContext. - Include the Jobs entities in your normal EF migration flow when the application manages schema through migrations.
Database.EnsureCreated()is appropriate for tests and isolated prototypes; production hosts should use their normal migration/deployment path.- The EF provider replaces only the persistence implementation. Dispatch, query, maintenance, and endpoint APIs stay the same.
If the host already has several module DbContexts, the Jobs provider can attach to whichever one should own scheduler state. The important part is that the chosen context implements IJobsContext; migrations for that context will include the annotated Jobs entities.
Authoring Notes¶
- Jobs and triggers remain code-first definitions. Durable storage never becomes the source of truth for registrations.
- Runtime state can override a registered job or trigger to enable, disable, pause, or resume it without mutating source code.
- Manual dispatch, recurring materialization, retries, chaining, and batch grouping all converge on the same durable occurrence model.
- Cron and calendar triggers default to
JobMissedOccurrencePolicy.RunOnce, so each scheduler sweep creates one occurrence for the latest due time in the covered window. Use.WithMissedOccurrencePolicy(JobMissedOccurrencePolicy.Skip)only when missed windows should advance without creating work. - Batches are grouping and bulk-control constructs, not workflow definitions. If you need durable business state transitions, use Orchestrations.
Lifetime And Resolved Properties¶
Job registrations can control the DI lifetime for the job type and can set the module explicitly.
builder.Services.AddJobScheduler()
.WithJob<ExportCustomerJob>("export-customer", job => job
.Name("Export Customer Projection")
.Description("Exports one customer projection.")
.Module("Reporting")
.UseLifetime(ServiceLifetime.Scoped)
.AddTrigger("manual", trigger => trigger.Manual()));
Resolution rules:
- the default job lifetime is transient
UseLifetime(ServiceLifetime.Scoped)creates one job instance per execution scopeUseLifetime(ServiceLifetime.Singleton)reuses the container singleton for every execution- if
.Module(...)is not configured andIModuleContextAccessoris registered, Jobs resolves the module from the job type throughIModuleContextAccessor.Find(jobType) - an explicit
.Module(...)value wins over module resolution fromIModuleContextAccessor - if
.Name(...)is omitted, Jobs derives the display name from the optional resolved module name plus the dashified job type name - query APIs and dashboard views expose the resolved module name and resolved display name, not only the raw fluent input
Advanced Usage¶
Typed Data, Properties, And Execution Context¶
Use a typed JobBase<TData> when the job contract needs a durable payload. The execution context then gives you strongly typed input, immutable properties, previous execution snapshots, mutable messages, and a mutable item bag.
public sealed record ExportCustomerJobData(string CustomerId, bool FullRebuild);
public sealed class ExportCustomerJob(ICustomerExporter exporter) : JobBase<ExportCustomerJobData>
{
public override async Task<Result> ExecuteAsync(
IJobExecutionContext<ExportCustomerJobData> context,
CancellationToken cancellationToken = default)
{
var tenant = context.Properties.Get("tenant", "default");
var previousCompletedUtc = context.PreviousSuccessfulExecution?.CompletedUtc;
var exportedCount = await exporter.ExportAsync(
context.Data.CustomerId,
context.Data.FullRebuild,
tenant,
previousCompletedUtc,
cancellationToken);
context.Messages.Add($"Exported {exportedCount} records for {context.Data.CustomerId}.");
context.Items["exportedCount"] = exportedCount;
context.Items["tenant"] = tenant;
return Result.Success();
}
}
Dispatch the job with payload and properties:
var scheduler = app.Services.GetRequiredService<IJobSchedulerService>();
var result = await scheduler.DispatchAndWaitAsync<ExportCustomerJob>(
new ExportCustomerJobData("cust-42", FullRebuild: false),
new JobDispatchOptions
{
TriggerName = "manual",
CorrelationId = "ops-export-42",
IdempotencyKey = "export:cust-42:2026-05-26",
Properties = new PropertyBag
{
["tenant"] = "alpha",
["requestedBy"] = "support-tool",
},
},
cancellationToken: cancellationToken);
Context guidance:
context.Datais the durable typed payload for the occurrencecontext.Propertiesis immutable for the active execution and is persisted with the occurrencecontext.Messagesis suitable for human-readable execution notes that should be returned with the resultcontext.Itemsis an in-memory item bag for the active attempt only; it is not the durable properties storecontext.PreviousExecutionandcontext.PreviousSuccessfulExecutionlet jobs implement incremental or compensating behavior without querying storage manually
Inline Delegate Jobs¶
Inline delegate jobs are useful when the registration site already owns the behavior and a separate class would add little value. They still execute through the normal definition, trigger, context, retry, timeout, lease, and history pipeline.
builder.Services.AddJobScheduler()
.WithJob("export-customer-inline", job => job
.Description("Exports one customer through an inline delegate.")
.Execute<ExportCustomerJobData>(async (context, serviceProvider, cancellationToken) =>
{
var tenant = context.Properties.Get("tenant", "default");
var exporter = serviceProvider.GetRequiredService<ICustomerExporter>();
await exporter.ExportAsync(
context.Data.CustomerId,
context.Data.FullRebuild,
tenant,
context.PreviousSuccessfulExecution?.CompletedUtc,
cancellationToken);
context.Messages.Add($"Inline export finished for {context.Data.CustomerId}.");
context.Items["tenant"] = tenant;
context.Items["mode"] = "inline-delegate";
return Result.Success();
})
.AddTrigger("manual", trigger => trigger.Manual()));
Use inline delegates for small, registration-local behavior. Prefer a dedicated job class when the logic is shared, grows beyond a compact lambda, or deserves its own explicit test seam and constructor dependency graph.
Behaviors¶
Jobs supports execution behaviors that wrap the scheduler's execution pipeline for a concrete attempt. Use them for cross-cutting concerns such as metrics, module scoping, validation, or deliberate fault injection in tests.
Register a global behavior when it should apply to every job:
builder.Services.AddJobScheduler()
.WithBehavior<ModuleScopeBehavior>()
.WithJob<ExportCustomerJob>("export-customer", job => job
.Description("Exports a customer projection.")
.AddTrigger("manual", trigger => trigger.Manual()));
Register a job-specific behavior when only one job needs it:
builder.Services.AddJobScheduler()
.WithJob<ExportCustomerJob>("export-customer", job => job
.Description("Exports a customer projection.")
.WithBehavior<ModuleScopeBehavior>()
.AddTrigger("manual", trigger => trigger.Manual()));
Declare a behavior on the job type when the behavior is part of the job's contract:
[WithBehavior(typeof(ModuleScopeBehavior))]
public sealed class ExportCustomerJob(ICustomerExporter exporter) : JobBase<ExportCustomerJobData>
{
public override Task<Result> ExecuteAsync(
IJobExecutionContext<ExportCustomerJobData> context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Result.Success());
}
}
Behavior notes:
JobMetricsBehavioris registered by default so execution spans and execution-level metrics continue to flow without extra setup- global behaviors run before job-specific fluent behaviors and attribute-declared behaviors
- attribute-declared behaviors run before fluent job-specific behaviors on the same job
- behaviors should wrap
next()and let the scheduler keep ownership of leases, retries, timeout handling, and durable state transitions
ModuleScopeBehavior captures the resolved module name in the log scope and current activity. If IModuleContextAccessor resolves the job into a disabled module, the behavior fails the attempt before the job body runs.
ChaosExceptionJobBehavior mirrors the repo's other chaos behaviors and is intended for failure-path testing. Implement IChaosExceptionJob on a job and register the behavior where you want chaos injection to apply:
builder.Services.AddJobScheduler()
.WithBehavior<ChaosExceptionJobBehavior>()
.WithJob<ImportOrdersJob>("import-orders", job => job
.Description("Imports orders with chaos injection enabled for tests.")
.AddTrigger("manual", trigger => trigger.Manual()));
public sealed class ImportOrdersJob : JobBase, IChaosExceptionJob
{
ChaosExceptionJobBehaviorOptions IChaosExceptionJob.Options => new()
{
InjectionRate = 1.0,
Fault = new ChaosException("Injected failure for resilience tests."),
};
public override Task<Result> ExecuteAsync(
IJobExecutionContext<Unit> context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Result.Success());
}
}
Trigger Properties Versus Dispatch Properties¶
Trigger properties are registration-time defaults. Dispatch properties are operation-time overlays. The scheduler merges them when materializing the occurrence, with explicit dispatch properties taking precedence.
builder.Services.AddJobScheduler()
.WithJob<ExportCustomerJob>("export-customer", job => job
.Description("Exports a customer projection.")
.AddTrigger("nightly", trigger => trigger
.Cron("0 30 1 * * *")
.WithProperty("tenant", "alpha")
.WithProperty("channel", "scheduled")));
await scheduler.DispatchAsync(
"export-customer",
new ExportCustomerJobData("cust-42", FullRebuild: true),
new JobDispatchOptions
{
TriggerName = "nightly",
Properties = new PropertyBag
{
["channel"] = "manual-repair",
["requestedBy"] = "operations",
},
},
cancellationToken);
Advanced Use Cases¶
Use the same core runtime for different operational patterns:
- immediate inline execution with
DispatchAndWaitAsync(...)when the caller must observe the terminal result - accepted asynchronous dispatch with
DispatchAsync(...)when the caller only needs an occurrence identifier and status tracking DispatchAsync(...)only accepts and persists the occurrence; the background scheduler executes it on an eligible scheduler instance- time-based recurring work through cron triggers
- business-calendar recurring work through calendar triggers and exclusions
- manual support workflows through manual triggers and property-enriched dispatch options
- incremental jobs that use
PreviousSuccessfulExecutionfor watermarks or delta windows - chained handoffs through
.Then(...)when one occurrence must wait for another - grouped fan-out and selective reprocessing through batches
- outbound integration jobs through
WithRequestSendJob(...)orWithRequesterJob(...),WithNotificationPublishJob(...)orWithNotifierJob(...),WithMessagingPublishJob(...)orWithMessagePublishJob(...),WithQueueingSendJob(...)orWithQueueSendJob(...),WithPipelineExecuteJob(...), andWithOrchestrationExecuteJob(...) - event-triggered jobs through accepted-event ingress and scheduler sweeps
Calendar Triggers¶
Use calendar triggers when cron syntax is the wrong abstraction and the business schedule is expressed in dates, weekdays, exclusions, or month-end rules.
builder.Services.AddJobScheduler()
.WithJob<ExportCustomerJob>("export-customer", job => job
.Description("Exports customer data on business-operational dates.")
.AddTrigger("calendar", trigger => trigger
.Calendar(calendar => calendar
.At(new TimeOnly(8, 30))
.OnBusinessDays()
.OnDaysOfMonth(1, 15)
.LastDayOfMonth()
.ExcludeDates(
new DateOnly(2026, 12, 25),
new DateOnly(2026, 12, 26)))));
Calendar trigger guidance:
- use
.OnBusinessDays()when weekends should never materialize work - use
.OnWeekdays(...)when only selected weekdays are valid - use
.OnDaysOfMonth(...)or.LastDayOfMonth()for month-boundary work - use
.ExcludeDates(...)for holidays or maintenance freezes - use
.TimeZone(...)or.InTimeZone(...)when the recurring local time is not UTC - combine calendar rules with normal trigger properties, retry policy, timeout, and chaining rules
Event Triggers¶
Event triggers let accepted outbound or messaging activity materialize normal scheduler occurrences. The trigger definition stays code-first, while the adapter converts a published notification, broker message, or queue message into a durable accepted-event row that the scheduler sweep turns into an occurrence.
builder.Services.AddJobScheduler()
.WithJob<ProcessExportCompletedJob>("process-export-completed", job => job
.Description("Handles accepted export-completed notifications.")
.WithData<CustomerExportCompletedNotification>()
.AddTrigger("accepted", trigger => trigger
.Event<CustomerExportCompletedNotification>(JobEventSourceNames.Notifier)));
builder.Services.AddNotifier()
.AddHandler<CustomerExportCompletedNotification, CustomerExportCompletedHandler>()
.UseJobSchedulerEventTriggers();
Accepted events should carry stable identity when possible. Use JobEventContextPropertyNames.IdempotencyKey, JobEventContextPropertyNames.SourceId, and JobEventContextPropertyNames.CorrelationId so duplicate published events do not materialize duplicate occurrences and support tooling can correlate the resulting work.
await notifier.PublishAsync(
new CustomerExportCompletedNotification(exportId),
new PublishOptions
{
Context = new RequestContext
{
Properties =
{
[JobEventContextPropertyNames.IdempotencyKey] = $"export:{exportId}",
[JobEventContextPropertyNames.CorrelationId] = correlationId,
},
},
},
cancellationToken);
Adapter notes:
- Notifier event triggers are enabled with
AddNotifier().UseJobSchedulerEventTriggers() - Messaging event triggers are enabled with
AddMessaging().UseJobSchedulerEventTriggers() - Queueing event triggers are enabled with
AddQueueing().UseJobSchedulerEventTriggers() - Messaging and Queueing adapters require
AddJobScheduler()to be configured beforeUseJobSchedulerEventTriggers()so the event-source registry exists - Accepted events are materialized during scheduler sweeps, not inline with the publishing call
Custom Triggers¶
Use a custom trigger when the schedule source is neither cron, calendar, manual, nor accepted events. A custom trigger provider keeps the scheduler core provider-neutral while still letting a job materialize occurrences from an external rule or state source.
builder.Services.AddSingleton<CustomerWindowTriggerProvider>();
builder.Services.AddJobScheduler()
.WithJob<RefreshCustomerWindowJob>("refresh-customer-window", job => job
.Description("Refreshes customer windows from an external planning source.")
.WithData<RefreshCustomerWindowData>()
.AddTrigger("external-window", trigger => trigger.Custom<CustomerWindowTriggerProvider>()));
public sealed class CustomerWindowTriggerProvider : IJobCustomTriggerProvider
{
public Result<JobTriggerEvaluationResult> Materialize(JobTriggerEvaluationContext context)
{
if (context.RuntimeState.HasMaterializedOccurrence)
{
return Result<JobTriggerEvaluationResult>.Success(new JobTriggerEvaluationResult(
context.RuntimeState,
[]));
}
var dueUtc = context.NowUtc.AddMinutes(15);
var data = new RefreshCustomerWindowData("alpha");
return Result<JobTriggerEvaluationResult>.Success(new JobTriggerEvaluationResult(
context.RuntimeState with { HasMaterializedOccurrence = true, UpdatedDate = context.NowUtc },
[
new JobOccurrenceMaterialization(
$"{context.Job.JobName}:{context.Trigger.TriggerName}:alpha",
context.Job.JobName,
context.Trigger.TriggerName,
JobTriggerType.Custom,
dueUtc,
null,
data,
typeof(RefreshCustomerWindowData),
new Dictionary<string, string> { ["tenant"] = "alpha" },
"alpha")
]));
}
}
Custom trigger guidance:
- register the provider type itself in DI; the scheduler resolves the concrete
Custom<TProvider>()type directly - return deterministic
OccurrenceKeyandIdempotencyKeyvalues so repeated sweeps do not create duplicate work - update
JobTriggerRuntimeStatewhen the provider needs one-shot or watermark behavior - prefer properties for durable correlation and
context.Itemsonly for attempt-local execution details
Testing Jobs¶
The Jobs feature ships xUnit-oriented testing utilities directly from Application.Jobs. You can use the same public helper types that the feature itself uses in its unit tests.
Use two testing styles:
- direct job tests with a synthetic
IJobExecutionContext<TData>when you only want to verify job logic - direct runtime tests with
JobTestHarnesswhen you want DI activation plus a synthetic execution context without running scheduler sweeps - scheduler tests with
JobSchedulerTestHarnesswhen you want to verify trigger materialization, retries, time, batches, dependencies, history, or control behavior
JobSchedulerTestHarness is also the right tool for event-trigger and custom-trigger coverage because it can materialize triggers, execute due work, and assert retained occurrence/history state without booting a full host.
Direct Job Tests With A Synthetic Execution Context¶
Use JobExecutionContextBuilder<TData> when you want a deterministic context without starting the scheduler runtime.
public class ExportCustomerJobTests
{
[Fact]
public async Task ExecuteAsync_UsesPreviousSuccessfulExecutionForDeltaWork()
{
var exporter = Substitute.For<ICustomerExporter>();
var sut = new ExportCustomerJob(exporter);
var context = new JobExecutionContextBuilder<ExportCustomerJobData>()
.WithJobName("export-customer")
.WithTriggerName("manual")
.WithData(new ExportCustomerJobData("cust-42", FullRebuild: false))
.WithProperties(new PropertyBag { ["tenant"] = "alpha" })
.WithPreviousSuccessfulExecution(new JobExecutionContextSnapshot(
ExecutionId: Guid.NewGuid(),
AttemptNumber: 1,
Status: JobExecutionStatus.Completed,
StartedUtc: new DateTimeOffset(2026, 05, 25, 09, 00, 00, TimeSpan.Zero),
CompletedUtc: new DateTimeOffset(2026, 05, 25, 09, 05, 00, TimeSpan.Zero),
Message: "previous export completed"))
.Build();
var result = await sut.ExecuteAsync(context);
result.IsSuccess.ShouldBeTrue();
context.Properties.Get("tenant", string.Empty).ShouldBe("alpha");
}
}
This path is useful for:
- typed payload handling
- property-dependent logic
context.Messagesandcontext.Items- previous-attempt and previous-success logic
- cancellation-token propagation into the job method
Direct Runtime Tests With JobTestHarness¶
Use JobTestHarness.Create() when you want the real DI activation path for one job, but still want a fully synthetic execution context.
public class ExportCustomerJobHarnessTests
{
[Fact]
public async Task ExecuteAsync_UsesRegisteredDependenciesAndPreviousState()
{
var exporter = Substitute.For<ICustomerExporter>();
using var harness = JobTestHarness.Create()
.WithJob<ExportCustomerJob>("export-customer")
.WithService(exporter)
.WithData(new ExportCustomerJobData("cust-42", FullRebuild: false))
.WithPreviousSuccessfulExecution(previous => previous
.Status(JobExecutionStatus.Completed)
.StartedUtc(new DateTimeOffset(2026, 05, 25, 09, 00, 00, TimeSpan.Zero))
.CompletedUtc(new DateTimeOffset(2026, 05, 25, 09, 05, 00, TimeSpan.Zero)))
.Build();
var result = await harness.ExecuteAsync();
result.IsSuccess.ShouldBeTrue();
}
}
Use this style when you want constructor-injected collaborators, typed payload validation, or previous-execution snapshots without materializing occurrences and leases.
Scheduler Tests With In-Memory Persistence¶
Use JobSchedulerTestHarness.Create(...) when you want to test scheduler behavior rather than only the job body.
public class ExportCustomerScheduleTests
{
[Fact]
public async Task CronTrigger_MaterializesAndExecutesDueOccurrence()
{
using var harness = JobSchedulerTestHarness.Create(jobs =>
jobs.WithJob<ExportCustomerJob>("export-customer", job => job
.Description("Exports customer data.")
.AddTrigger("cron", trigger => trigger.Cron("*/5 * * * *"))));
await harness.MaterializeAsync();
harness.Advance(TimeSpan.FromMinutes(5));
await harness.MaterializeAsync();
var occurrence = await harness.FindOccurrenceAsync("export-customer", "cron");
var result = await harness.ExecuteOccurrenceAsync(occurrence.OccurrenceId);
result.IsSuccess.ShouldBeTrue();
await harness.AssertHistoryContainsAsync(occurrence.OccurrenceId, "ExecutionCompleted");
}
}
The scheduler harness gives you:
- in-memory persistence for occurrences, batches, leases, dependencies, and history
- a fake clock through
harness.Clockandharness.Advance(...) - deterministic trigger materialization through
MaterializeAsync(...) - a fluent public builder through
JobSchedulerTestHarness.Create().WithClock(...).WithJob(...).Build() - direct dispatch and dispatch-and-wait helpers
- assertions for retry attempts, blocked dependencies, batch roll-up, and history entries
- DI customization for fake outbound dependencies through
configureServices
Typical cases to test with the harness:
- cron, one-time, delayed, startup-delay, manual, and event-trigger materialization
- retries, timeout handling, cancellation, and lease recovery
- previous-run state hydration for later executions
- batch roll-up and bulk-control behavior
- dependency blocking and chained successor materialization
If you need a durable-provider test, keep the same job/trigger authoring model and swap the in-memory setup for the Entity Framework provider in a higher-level integration test.
Architecture¶
The Jobs feature is intentionally split between registration-time definitions, a durable runtime model, and operational services that work against the same persisted state.
Architectural Responsibilities¶
- authoring defines stable job names, trigger names, data contracts, concurrency, retries, and chaining rules
- runtime services create and mutate occurrences, batches, leases, runtime overrides, executions, and history
- query services shape persisted state into dashboard and support views without exposing raw storage concerns to callers
- maintenance services repair or purge retained scheduler data through provider-neutral contracts
- optional web endpoints expose the same operational model over HTTP for support tooling and dashboards
flowchart LR
A[Application modules] --> B[AddJobScheduler builder calls]
I[Integrations and maintenance helpers] --> B
B --> C[JobRegistrationStore]
C --> D[JobSchedulerService]
D --> E[JobSchedulerBackgroundService]
D --> F[IJobStoreProvider]
E --> F
G[IJobSchedulerQueryService] --> F
H[IJobSchedulerMaintenanceService] --> F
J[Presentation.Web.Jobs] --> D
J --> G
J --> H
F --> K[In-memory provider]
F --> L[Entity Framework provider]
Dispatch And Execution Flow¶
Every trigger materialization, manual dispatch, retry, and batch item becomes a durable occurrence. The background service then turns due occurrences into executions by acquiring leases, invoking the job type, and recording execution history.
sequenceDiagram
participant Caller as App or endpoint
participant Scheduler as IJobSchedulerService
participant Registry as JobRegistrationStore
participant Store as IJobStoreProvider
participant Runtime as JobSchedulerBackgroundService
participant Job as IJob
Caller->>Scheduler: DispatchAsync / DispatchBatchAsync / RetryOccurrencesAsync
Scheduler->>Registry: Resolve job and trigger definitions
Scheduler->>Store: Persist occurrence, batch, and runtime mutations
Runtime->>Store: Query due occurrences and acquire lease
Runtime->>Job: ExecuteAsync(context)
Job-->>Runtime: Result
Runtime->>Store: Persist execution, history, occurrence status, and lease release
Durable Data Shape¶
The persisted model is occurrence-centered. Jobs and triggers stay code-first definitions, while the database stores operational state and history around those definitions.
classDiagram
class JobRuntimeState {
JobName
Enabled
Paused
}
class JobTriggerRuntimeState {
JobName
TriggerName
Enabled
Paused
}
class JobOccurrence {
OccurrenceId
JobName
TriggerName
Status
ScheduledAtUtc
}
class JobLease {
LeaseId
OccurrenceId
ExpiresAtUtc
}
class JobExecution {
ExecutionId
OccurrenceId
Attempt
Status
}
class JobExecutionHistory {
HistoryId
ExecutionId
Severity
Message
}
class JobBatch {
BatchId
Status
TotalCount
}
class JobBatchOccurrence {
BatchId
OccurrenceId
}
class JobOccurrenceDependency {
OccurrenceId
DependsOnOccurrenceId
}
JobOccurrence --> JobLease : active lease
JobOccurrence --> JobExecution : execution attempts
JobExecution --> JobExecutionHistory : history entries
JobBatch --> JobBatchOccurrence : contains
JobOccurrence --> JobBatchOccurrence : member of
JobOccurrence --> JobOccurrenceDependency : prerequisites
Entity Framework Tables¶
When the Entity Framework provider is enabled, the durable scheduler state is stored in a small set of operational tables using the __Jobs_ prefix. These tables do not replace the code-first registration model. They only persist runtime overlays, materialized work, coordination state, and retained history around the registered jobs and triggers.
Core runtime-state tables:
__Jobs_RuntimeStates: one row per registered job when durable runtime overrides exist. Stores operational enable/disable and pause/resume state without changing the source registration.__Jobs_TriggerRuntimeStates: one row per registered trigger when trigger-specific runtime overrides exist. Stores trigger-level enable/disable and pause/resume state, plus the persisted timing/event watermark fields needed for deterministic materialization.
Core work-execution tables:
__Jobs_Occurrences: the central durable work table. Each row is one materialized or manually accepted occurrence with job name, trigger name, trigger type, status, scheduling timestamps, correlation/idempotency data, properties, and serialized payload.__Jobs_Executions: one row per execution attempt for an occurrence. Stores attempt number, scheduler instance, timing, result status, message, and exception details for the specific run.__Jobs_ExecutionHistory: append-only retained history for support and diagnostics. Captures lifecycle events such as accepted, materialized, retried, cancelled, archived, recovered, or lease-related transitions.__Jobs_Leases: active lease ownership rows used to coordinate execution across multiple scheduler nodes. A lease row indicates which scheduler instance currently owns an occurrence and until when that ownership is valid.
Batching and dependency tables:
__Jobs_Batches: the durable batch aggregate row. Stores the external/public batch id, completion policy, aggregate status, counters, correlation information, and completion/archive timestamps.__Jobs_BatchOccurrences: the batch membership join table linking a batch to its child occurrences. Also stores the child sequence and the last known child status so batch rollups can be recomputed efficiently.__Jobs_OccurrenceDependencies: prerequisite links between occurrences created by chaining or dependency-aware dispatch. These rows let one occurrence remain blocked until the prerequisite occurrence reaches a required terminal status.
Event-ingress table:
__Jobs_AcceptedEvents: accepted external events waiting to be materialized into occurrences. Event-trigger adapters write here first, and scheduler sweeps later convert those rows into durable occurrences using the registered event-trigger definitions.
Operationally, the most important relationships are:
- one job occurrence can have many execution attempts and many retained history entries
- one job batch can have many batch-membership rows, each pointing to one occurrence
- one occurrence can participate in dependency rows as either the prerequisite or the dependent side
- one occurrence can have at most one active lease row at a time during execution ownership
That split keeps the physical model aligned with runtime responsibilities: runtime-state tables govern whether registered definitions may run, occurrence/execution tables govern actual work, lease tables govern distributed coordination, and history/event tables preserve the support trail around that work.
Runtime Semantics¶
- Registrations define what can run; persisted runtime state defines how a registration is currently allowed to run.
- Trigger evaluation is stateless with respect to source code but stateful with respect to persisted runtime overrides and accepted external events.
- Execution is lease-based so multiple hosts can cooperate without intentionally running the same occurrence concurrently.
- Batches aggregate multiple ordinary occurrences; chaining links ordinary occurrences through dependency rows.
- Maintenance operations work on retained state only and do not mutate the source registration model.
Observability And Telemetry¶
Jobs emits runtime telemetry through ActivitySource and Meter using the shared source name BridgingIT.DevKit.Application.Jobs.
Execution-level telemetry is emitted through the default JobMetricsBehavior, which wraps the job execution pipeline. Sweep, materialization, lease, accepted-event, management, and maintenance telemetry remain runtime-owned because they are not job-body concerns.
Tracing creates spans for:
- scheduler sweep cycles
- trigger materialization
- lease acquisition and renewal
- job execution
- retry scheduling
- accepted-event ingress
- management and maintenance operations
Metrics cover:
- execution starts, completions, failures, retries, timeouts, cancellations, and interruptions
- execution duration
- occurrence age at execution start
- materialized occurrence counts
- event acceptance counts
- lease acquisition, renewal, and recovery counts
- worker-pool utilization snapshots during sweeps and execution starts
- management and maintenance operation counts
Common telemetry tags include job name, trigger name, trigger type, occurrence id, execution id, scheduler instance id, correlation id, and lease owner when that information exists.
sequenceDiagram
participant Sweep as Scheduler sweep
participant Store as Store provider
participant Job as Job execution
participant Telemetry as ActivitySource + Meter
Sweep->>Telemetry: jobs.sweep
Sweep->>Store: materialize due triggers
Sweep->>Telemetry: jobs.trigger.materialize + jobs.occurrences.materialized
Sweep->>Store: acquire lease
Sweep->>Telemetry: jobs.lease.acquire
Sweep->>Job: execute occurrence
Job->>Telemetry: jobs.execution + execution metrics
Job->>Store: append history / update occurrence
Job->>Telemetry: jobs.retry.schedule or terminal execution metrics
If you pass JobDispatchOptions.CorrelationId or an accepted-event correlation id, that identifier flows into IJobExecutionContext, durable history, and telemetry tags for the resulting execution path.
Operational Services¶
Runtime Control¶
Use IJobSchedulerService when application code or support tooling needs to mutate scheduler state.
var scheduler = app.Services.GetRequiredService<IJobSchedulerService>();
await scheduler.DisableJobAsync("cleanup-customers", "Freeze scheduled cleanup", cancellationToken);
await scheduler.EnableJobAsync("cleanup-customers", "Re-enable after maintenance", cancellationToken);
await scheduler.PauseTriggerAsync("cleanup-customers", "nightly", "Pause nightly trigger", cancellationToken);
await scheduler.ResumeTriggerAsync("cleanup-customers", "nightly", "Resume nightly trigger", cancellationToken);
await scheduler.RetryOccurrencesAsync([occurrenceId1, occurrenceId2], "Retry failed export occurrences", cancellationToken);
The control surface covers:
- dispatch and dispatch-and-wait
- enable, disable, pause, and resume for jobs and triggers
- pause, resume, cancel, interrupt, retry, archive, and lease release for occurrences
- batch creation, dispatch, attach, retry, cancel, pause, resume, and archive
- bulk occurrence retry, cancel, and archive
Query Models¶
Use IJobSchedulerQueryService when a UI, dashboard, or support endpoint needs shaped operational views rather than raw persistence rows.
var query = app.Services.GetRequiredService<IJobSchedulerQueryService>();
var retries = await query.QueryRetriesAsync(new JobSchedulerRetryQueryRequest
{
JobName = "cleanup-customers",
Take = 25,
}, cancellationToken);
var metrics = await query.GetMetricsAsync(new JobSchedulerMetricsRequest(), cancellationToken);
Common operational query parameters:
GET /_bdk/api/jobssupportsjobName,group,module,enabled,includeOrphanedRuntimeState,skip,take,sortBy, andsortDescendingGET /_bdk/api/jobs/occurrencesandGET /_bdk/api/jobs/executionssupportjobName,triggerName,triggerType,statuses,schedulerInstanceId,correlationId,idempotencyKey,dueFrom,dueTo,startedFrom,startedTo,completedFrom,completedTo,skip,take,sortBy, andsortDescendingGET /_bdk/api/jobs/dashboard/timelinesupportsfrom,to,bucket,mode,jobName,triggerName,schedulerInstanceId, andstatuses
When includeOrphanedRuntimeState=true, the jobs list also returns synthetic support rows for persisted runtime-state records whose job registration no longer exists. Registered jobs with removed trigger runtime-state rows expose that condition through the same query surface so support tooling can spot cleanup work without reading provider tables directly.
Maintenance APIs¶
Use IJobSchedulerMaintenanceService for provider-neutral support operations that work across in-memory and durable providers.
var maintenance = app.Services.GetRequiredService<IJobSchedulerMaintenanceService>();
var purge = await maintenance.PurgeOccurrencesAsync(new JobPurgeOccurrencesRequest
{
OlderThan = DateTimeOffset.UtcNow.AddDays(-30),
Statuses = [JobOccurrenceStatus.Archived, JobOccurrenceStatus.Completed],
JobName = "cleanup-customers",
BatchSize = 500,
}, cancellationToken);
The maintenance surface covers:
- retained occurrence archive
- retained occurrence purge
- archived history purge
- expired lease release
- stuck-occurrence recovery
- orphaned runtime-state detection
Examples¶
Database Cleanup¶
Built-in maintenance jobs are normal jobs. Register them once and dispatch them explicitly when needed:
builder.Services.AddJobScheduler()
.WithBuiltInMaintenanceJobs();
var scheduler = app.Services.GetRequiredService<IJobSchedulerService>();
await scheduler.DispatchAndWaitAsync<JobsArchiveOccurrencesJob>(
new JobArchiveOccurrencesJobData
{
RetentionWindow = TimeSpan.FromDays(14),
BatchSize = 250,
DryRun = false,
},
cancellationToken: cancellationToken);
await scheduler.DispatchAndWaitAsync<JobsPurgeHistoryJob>(
new JobPurgeHistoryJobData
{
RetentionWindow = TimeSpan.FromDays(14),
BatchSize = 250,
DryRun = false,
},
cancellationToken: cancellationToken);
Maintenance job options such as retention windows, batch sizes, dry-run mode, and target filters are passed as typed dispatch data. They are not read from appsettings; code remains the source of truth for what a maintenance run is allowed to do.
Purging Retained Occurrences¶
Use the maintenance service when you want provider-neutral purge behavior from application code or support tooling.
var maintenance = app.Services.GetRequiredService<IJobSchedulerMaintenanceService>();
var result = await maintenance.PurgeOccurrencesAsync(new JobPurgeOccurrencesRequest
{
OlderThan = DateTimeOffset.UtcNow.AddDays(-14),
Statuses = [JobOccurrenceStatus.Archived],
JobName = "cleanup-customers",
TriggerName = "nightly",
IsArchived = true,
BatchSize = 250,
}, cancellationToken);
Console.WriteLine($"Purged {result.ProcessedCount} occurrence(s).");
Batch Processing¶
Use batch APIs for grouped dispatch and targeted retry of failed children:
var scheduler = app.Services.GetRequiredService<IJobSchedulerService>();
var batch = await scheduler.DispatchBatchAsync(new JobBatchDispatchRequest
{
BatchId = "customer-reprocess-2026-05-26",
Description = "Reprocess failed customer exports.",
Items =
[
new JobBatchDispatchItem { JobName = "export-customer", Data = new ExportCustomerJobData("cust-1") },
new JobBatchDispatchItem { JobName = "export-customer", Data = new ExportCustomerJobData("cust-2") },
],
}, cancellationToken);
if (batch.IsSuccess)
{
await scheduler.RetryBatchAsync(batch.Value.BatchId, "Reprocess failed exports", cancellationToken);
}
Chaining¶
Use .Then(...) to express occurrence prerequisites while keeping each step as a normal job occurrence:
builder.Services.AddJobScheduler()
.WithJob<ImportOrdersJob>("import-orders", job => job
.Description("Imports orders from the upstream system.")
.AddTrigger("manual", trigger => trigger.Manual())
.Then("index-orders", chain => chain.WithTrigger("manual")))
.WithJob<IndexOrdersJob>("index-orders", job => job
.Description("Rebuilds the order search index.")
.AddTrigger("manual", trigger => trigger.Manual()));
The successor still has its own trigger, lease, retry, timeout, and execution history. Chaining adds a dependency between occurrences; it does not create a workflow engine or dynamic trigger definition store.
Migrating From Application.JobScheduling¶
The old Application.JobScheduling feature and the new Application.Jobs feature can exist during a source migration, but a single job should be owned by only one scheduler at a time.
- Move the job body into an
IJoborJobBase<TData>implementation. - Register the job with
AddJobScheduler().WithJob<TJob>("stable-job-name", ...). - Replace old delay, startup, and cron registrations with manual, startup-delay, one-time, or cron triggers.
- Replace inline execution calls with
DispatchAndWaitAsync(...)only when the caller must observe the terminal result. - Replace fire-and-forget scheduling with
DispatchAsync(...); it persists an occurrence and returns its id for tracking. - Move cleanup and repair work to built-in maintenance jobs or
IJobSchedulerMaintenanceService. - Add the Jobs EF entities to the owning DbContext migration before enabling durable execution in production.
- Disable the old registration for each migrated job after the new registration and operational checks are in place.
builder.Services.AddJobScheduler()
.WithJob<CleanupCustomersJob>("cleanup-customers", job => job
.Description("Removes stale customers.")
.AddTrigger("nightly", trigger => trigger.Cron("0 0 2 * * *")));
await scheduler.DispatchAsync(
"cleanup-customers",
new CleanupCustomersJobData(RetentionDays: 90),
cancellationToken: cancellationToken);
Keep stable names intentionally. A changed job name or trigger name creates a new scheduler identity and will not automatically inherit old occurrence history.
Optional Integrations¶
Requester, Notifier, and Pipeline integrations remain ordinary jobs registered through the core Jobs package because they depend only on Common abstractions. Messaging, Queueing, and Orchestrations expose their job builders from their own feature packages and depend only on Common.Abstractions job integration contracts, not on the Jobs runtime package. Consumers opt into those feature packages only when they already use the corresponding feature.
| Package | Builder API | Purpose |
|---|---|---|
Application.Jobs |
WithRequestSendJob(...) or WithRequesterJob(...) |
Send a request through the devkit requester runtime. |
Application.Jobs |
WithCommandJob(...) |
Semantic alias for requester-backed jobs that dispatch commands returning Result. |
Application.Jobs |
WithNotificationPublishJob(...) or WithNotifierJob(...) |
Publish a notification through the devkit notifier runtime. |
Application.Messaging |
WithMessagingPublishJob(...) or WithMessagePublishJob(...) |
Publish a broker message through IMessageBroker through Common.Abstractions job contracts. |
Application.Queueing |
WithQueueingSendJob(...) or WithQueueSendJob(...) |
Enqueue a queue message through IQueueBroker through Common.Abstractions job contracts. |
Application.Jobs |
WithPipelineExecuteJob(...) |
Execute a packaged pipeline through IPipelineFactory. |
Application.Orchestrations |
WithOrchestrationExecuteJob(...) |
Execute or dispatch a code-first orchestration through IOrchestrationService through Common.Abstractions job contracts. |
The paired names are equivalent. The spec-shaped aliases exist for readability and migration clarity; the scheduler runtime and stored job definitions are the same either way.
Notifier jobs look like this:
builder.Services.AddNotifier()
.AddHandler<CustomerExportCompletedNotification, CustomerExportCompletedHandler>();
builder.Services.AddJobScheduler()
.WithNotifierJob<CustomerExportCompletedData, CustomerExportCompletedNotification>("notify-export-complete", job => job
.Description("Publishes a customer export completion notification.")
.WithNotification(context => new CustomerExportCompletedNotification(context.Data.ExportId))
.MapCorrelationId()
.MapContextProperty("tenant")
.AddTrigger("manual", trigger => trigger.Manual()));
Messaging-backed jobs publish normal broker messages and can map job properties or correlation values into message properties:
builder.Services.AddJobScheduler()
.WithMessagePublishJob<CustomerExportCompletedData, CustomerExportCompletedMessage>("publish-export-message", job => job
.Description("Publishes a broker message for a completed export.")
.WithMessage(context => new CustomerExportCompletedMessage
{
ExportId = context.Data.ExportId,
})
.MapCorrelationId()
.MapContextProperty("tenant")
.AddTrigger("manual", trigger => trigger.Manual()));
Queueing-backed jobs enqueue normal queue messages and can optionally wait for provider-specific persistence confirmation:
builder.Services.AddJobScheduler()
.WithQueueSendJob<CustomerExportCompletedData, CustomerExportQueueMessage>("queue-export-message", job => job
.Description("Enqueues a follow-up export work item.")
.WithMessage(context => new CustomerExportQueueMessage
{
ExportId = context.Data.ExportId,
})
.MapCorrelationId()
.MapContextProperty("tenant")
.WaitForPersistence()
.AddTrigger("manual", trigger => trigger.Manual()));
Pipeline-backed jobs are useful when a recurring or manual scheduler entry should start a packaged processing pipeline instead of a standalone job class:
builder.Services.AddJobScheduler()
.WithPipelineExecuteJob<GenerateInvoiceJobData, GenerateInvoicePipeline, GenerateInvoicePipelineContext>("generate-invoice-pipeline", job => job
.Description("Starts the invoice generation pipeline.")
.WithContext(context => new GenerateInvoicePipelineContext
{
InvoiceId = context.Data.InvoiceId,
CorrelationId = context.CorrelationId,
})
.AddTrigger("manual", trigger => trigger.Manual()));
Orchestration-backed jobs can execute inline or dispatch durably, depending on whether the scheduler should wait for orchestration completion or only start the orchestration instance:
builder.Services.AddJobScheduler()
.WithOrchestrationExecuteJob<ClosePeriodJobData, ClosePeriodOrchestration, ClosePeriodOrchestrationData>("close-period", job => job
.Description("Starts the close-period orchestration.")
.WithInput(context => new ClosePeriodOrchestrationData
{
PeriodId = context.Data.PeriodId,
})
.Dispatch()
.AddTrigger("manual", trigger => trigger.Manual()));
Operational Notes¶
Endpoint Surface¶
Presentation.Web.Jobs exposes the same scheduler/query/maintenance surface over HTTP. The default route group is /_bdk/api/jobs.
Read Endpoints¶
| Method | Route | Description |
|---|---|---|
GET |
/_bdk/api/jobs |
List registered jobs with runtime overlay, paging, filtering, and sorting. |
GET |
/_bdk/api/jobs/{jobName} |
Get one job definition with operational state. |
GET |
/_bdk/api/jobs/definitions/{jobName} |
Canonical definition route for one job definition. |
GET |
/_bdk/api/jobs/triggers |
List triggers across all jobs. |
GET |
/_bdk/api/jobs/triggers/recurring |
List recurring triggers across all jobs. |
GET |
/_bdk/api/jobs/recurring |
Spec-shaped alias for recurring triggers. |
GET |
/_bdk/api/jobs/recurring/{jobName}/{triggerName} |
Get one recurring trigger projection. |
GET |
/_bdk/api/jobs/{jobName}/triggers |
List triggers for one job. |
GET |
/_bdk/api/jobs/{jobName}/triggers/{triggerName} |
Get one trigger using the spec-shaped route. |
GET |
/_bdk/api/jobs/definitions/{jobName}/triggers/{triggerName} |
Canonical definition route for one trigger. |
GET |
/_bdk/api/jobs/occurrences |
List materialized occurrences. |
GET |
/_bdk/api/jobs/occurrences/{occurrenceId} |
Get one occurrence by internal identifier. |
GET |
/_bdk/api/jobs/occurrences/{occurrenceId}/history |
Get retained history for one occurrence. |
GET |
/_bdk/api/jobs/retries |
List retry-scheduled or retryable occurrences. |
GET |
/_bdk/api/jobs/dependencies |
List persisted occurrence dependency links with dependent and prerequisite state. |
GET |
/_bdk/api/jobs/batches |
List durable batches. |
GET |
/_bdk/api/jobs/batches/{batchId} |
Get one batch. |
GET |
/_bdk/api/jobs/batches/{batchId}/occurrences |
List child occurrences for one batch. |
GET |
/_bdk/api/jobs/batches/{batchId}/history |
List append-only batch history for one batch. |
GET |
/_bdk/api/jobs/executions |
List execution attempts. |
GET |
/_bdk/api/jobs/history |
List retained execution history. |
GET |
/_bdk/api/jobs/leases |
List active and expired lease diagnostics. |
GET |
/_bdk/api/jobs/servers |
List observed scheduler server instances. |
GET |
/_bdk/api/jobs/metrics |
Get aggregate operational metrics. |
Dashboard Endpoints¶
| Method | Route | Description |
|---|---|---|
GET |
/_bdk/api/jobs/dashboard |
Primary dashboard summary projection. |
GET |
/_bdk/api/jobs/dashboard/summary |
Aggregate counts and diagnostics for the dashboard. |
GET |
/_bdk/api/jobs/dashboard/timeline |
Timeline buckets for occurrences or executions. |
GET |
/_bdk/api/jobs/dashboard/navigation |
Navigation projection derived from dashboard summary data. |
GET |
/_bdk/api/jobs/dashboard/overview |
Overview projection derived from dashboard summary data. |
Dashboard projections:
/_bdk/api/jobs/dashboard/navigationreturns job facet counts plus link descriptors that support tools can use to build a navigation shell without private provider access/_bdk/api/jobs/dashboard/overviewreturns headline counts for enabled triggers, due/running/failed work, retries, leases, batches, servers, and the oldest due occurrence
Job And Trigger Control Endpoints¶
| Method | Route | Description |
|---|---|---|
POST |
/_bdk/api/jobs/{jobName}/dispatch |
Dispatch a registered job using the spec-shaped route. |
POST |
/_bdk/api/jobs/definitions/{jobName}/dispatch |
Dispatch a registered job using the canonical definition route. |
POST |
/_bdk/api/jobs/{jobName}/enable |
Enable a job through durable runtime state. |
POST |
/_bdk/api/jobs/definitions/{jobName}/enable |
Canonical route for enabling a job. |
POST |
/_bdk/api/jobs/{jobName}/disable |
Disable a job through durable runtime state. |
POST |
/_bdk/api/jobs/definitions/{jobName}/disable |
Canonical route for disabling a job. |
POST |
/_bdk/api/jobs/{jobName}/pause |
Pause a job without mutating its code-first definition. |
POST |
/_bdk/api/jobs/definitions/{jobName}/pause |
Canonical route for pausing a job. |
POST |
/_bdk/api/jobs/{jobName}/resume |
Resume a paused job. |
POST |
/_bdk/api/jobs/definitions/{jobName}/resume |
Canonical route for resuming a job. |
POST |
/_bdk/api/jobs/{jobName}/triggers/{triggerName}/enable |
Enable a trigger through durable runtime state. |
POST |
/_bdk/api/jobs/definitions/{jobName}/triggers/{triggerName}/enable |
Canonical route for enabling a trigger. |
POST |
/_bdk/api/jobs/{jobName}/triggers/{triggerName}/disable |
Disable a trigger through durable runtime state. |
POST |
/_bdk/api/jobs/definitions/{jobName}/triggers/{triggerName}/disable |
Canonical route for disabling a trigger. |
POST |
/_bdk/api/jobs/{jobName}/triggers/{triggerName}/pause |
Pause a trigger without changing source registration. |
POST |
/_bdk/api/jobs/definitions/{jobName}/triggers/{triggerName}/pause |
Canonical route for pausing a trigger. |
POST |
/_bdk/api/jobs/{jobName}/triggers/{triggerName}/resume |
Resume a paused trigger. |
POST |
/_bdk/api/jobs/definitions/{jobName}/triggers/{triggerName}/resume |
Canonical route for resuming a trigger. |
Occurrence Control Endpoints¶
| Method | Route | Description |
|---|---|---|
POST |
/_bdk/api/jobs/occurrences/{occurrenceId}/pause |
Pause an eligible occurrence before another attempt starts. |
POST |
/_bdk/api/jobs/occurrences/{occurrenceId}/resume |
Resume a paused occurrence. |
POST |
/_bdk/api/jobs/occurrences/{occurrenceId}/cancel |
Request cancellation of an occurrence. |
POST |
/_bdk/api/jobs/occurrences/{occurrenceId}/interrupt |
Request interruption of a running occurrence. |
POST |
/_bdk/api/jobs/occurrences/{occurrenceId}/retry |
Retry an eligible failed occurrence. |
POST |
/_bdk/api/jobs/occurrences/{occurrenceId}/archive |
Archive a terminal occurrence. |
POST |
/_bdk/api/jobs/occurrences/{occurrenceId}/repair/release-lease |
Release an active lease for repair or recovery. |
POST |
/_bdk/api/jobs/occurrences/bulk/retry |
Retry selected eligible failed occurrences as one bulk operation. |
POST |
/_bdk/api/jobs/occurrences/bulk/cancel |
Cancel selected eligible occurrences as one bulk operation. |
POST |
/_bdk/api/jobs/occurrences/bulk/archive |
Archive selected eligible retained occurrences as one bulk operation. |
DELETE |
/_bdk/api/jobs/occurrences |
Purge retained terminal occurrences by age and optional filters. |
Batch Endpoints¶
| Method | Route | Description |
|---|---|---|
POST |
/_bdk/api/jobs/batches |
Create an empty or described durable batch record. |
POST |
/_bdk/api/jobs/batches/dispatch |
Create a batch and dispatch its child occurrences in one operation. |
POST |
/_bdk/api/jobs/batches/{batchId}/attach |
Attach additional occurrences to an existing batch. |
POST |
/_bdk/api/jobs/batches/{batchId}/retry |
Retry eligible failed child occurrences for a batch. |
POST |
/_bdk/api/jobs/batches/{batchId}/cancel |
Cancel eligible child occurrences for a batch. |
POST |
/_bdk/api/jobs/batches/{batchId}/pause |
Pause eligible child occurrences for a batch. |
POST |
/_bdk/api/jobs/batches/{batchId}/resume |
Resume eligible child occurrences for a batch. |
POST |
/_bdk/api/jobs/batches/{batchId}/archive |
Archive a batch and eligible retained child occurrences. |
Maintenance Endpoints¶
| Method | Route | Description |
|---|---|---|
POST |
/_bdk/api/jobs/maintenance/purge-history |
Purge archived execution history older than the retention window. |
POST |
/_bdk/api/jobs/maintenance/release-expired-leases |
Release expired leases and repair affected occurrences. |
POST |
/_bdk/api/jobs/maintenance/recover-stuck-occurrences |
Recover stale occurrences without a valid active lease. |
POST |
/_bdk/api/jobs/maintenance/detect-orphaned-runtime-state |
Detect runtime-state rows that no longer match active registrations. |
The occurrence purge endpoint accepts query parameters comparable to other retained operational endpoints:
DELETE /_bdk/api/jobs/occurrences?olderThan=2026-04-01T00:00:00Z&statuses=Archived&jobName=cleanup-customers&triggerName=nightly&isArchived=true&batchSize=250
Operational Semantics¶
- Event-trigger adapters accept external events and materialize occurrences asynchronously during scheduler sweeps.
- Event-trigger idempotency is based on source identity or an explicit idempotency key.
- Custom triggers are resolved from DI and can materialize provider-neutral occurrences without persisting provider-specific scheduler state.
- Maintenance jobs run through the same scheduler pipeline as application jobs and can be dispatched in dry-run mode.
- The built-in maintenance catalog includes
jobs-archive-occurrences,jobs-purge-history,jobs-release-expired-leases,jobs-recover-stuck-occurrences, andjobs-detect-orphaned-runtime-state. - Maintenance service purge operations remove dependent executions, history, dependencies, leases, and batch memberships, then refresh affected batch rollups.
- Batch membership uses
JobBatchOccurrence; chaining usesJobOccurrenceDependency. - Persisted runtime state augments active registrations, but it never becomes the source of truth for job or trigger definitions.