Skip to content

Add Background Jobs

Granit.BackgroundJobs provides durable, cluster-safe recurring job scheduling built on Wolverine. Jobs are plain Wolverine messages decorated with [RecurringJob] — no special interfaces to implement, no duplicate executions in multi-node deployments.

  • A working Granit application with Granit.Wolverine configured
  • Granit.Wolverine.Postgresql for transactional outbox (production)
Terminal window
dotnet add package Granit.BackgroundJobs

For production persistence and administration endpoints:

Terminal window
dotnet add package Granit.BackgroundJobs.EntityFrameworkCore
dotnet add package Granit.BackgroundJobs.Endpoints
using Granit.Core.Modularity;
using Granit.BackgroundJobs;
using Granit.BackgroundJobs.EntityFrameworkCore;
using Granit.BackgroundJobs.Endpoints;
[DependsOn(
typeof(GranitBackgroundJobsModule),
typeof(GranitBackgroundJobsEntityFrameworkCoreModule),
typeof(GranitBackgroundJobsEndpointsModule))]
public sealed class MyAppModule : GranitModule { }
{
"BackgroundJobs": {
"Mode": "Durable"
}
}
ModePackageBehavior
InMemoryGranit.BackgroundJobsState lost on restart. Use for development and tests.
DurableGranit.BackgroundJobs.EntityFrameworkCoreState persisted in PostgreSQL/SQL Server. Use for production.

A recurring job consists of a message class with the [RecurringJob] attribute and a standard Wolverine handler:

[RecurringJob("0 8 * * *", "daily-report")]
public sealed class GenerateDailyReportCommand;
public static class GenerateDailyReportHandler
{
public static async Task Handle(
GenerateDailyReportCommand command,
IReportService reportService,
CancellationToken cancellationToken)
{
await reportService.GenerateAsync(cancellationToken);
// The middleware automatically schedules the next occurrence after this returns.
}
}

The first argument is a Cronos cron expression. The second is a unique job name used for administration (pause, resume, trigger).

Granit uses Cronos for cron parsing. Both 5-field (no seconds) and 6-field (with seconds) formats are supported.

ExpressionMeaning
0 * * * *Every hour at minute 0
0 8 * * *Every day at 08:00 UTC
0 8 * * 1Every Monday at 08:00 UTC
*/5 * * * *Every 5 minutes
0 0 1 * *First of each month at 00:00 UTC
0 */30 * * * *Every 30 seconds (6-field)

All occurrences are calculated in UTC. If you need tenant-local time, use ICurrentTimezoneProvider inside the handler.

app.MapBackgroundJobsEndpoints();

Or with custom options:

app.MapBackgroundJobsEndpoints(opts =>
{
opts.RoutePrefix = "admin/jobs";
opts.RequiredRole = "ops-team";
});
MethodRouteDescription
GET/{prefix}List all jobs with status
GET/{prefix}/{name}Detail of a single job
POST/{prefix}/{name}/pauseSuspend scheduling
POST/{prefix}/{name}/resumeResume scheduling
POST/{prefix}/{name}/triggerExecute immediately (202 Accepted)

All endpoints require the BackgroundJobs.Jobs.Manage permission.

Inject IBackgroundJobManager to control jobs from your own code:

public sealed class MaintenanceService(IBackgroundJobManager jobManager)
{
public async Task PauseReportingAsync(CancellationToken cancellationToken)
{
await jobManager.PauseAsync("daily-report", cancellationToken);
}
public async Task TriggerNowAsync(CancellationToken cancellationToken)
{
await jobManager.TriggerNowAsync("daily-report", cancellationToken);
}
}

IBackgroundJobManager.GetAllAsync() returns a list of BackgroundJobStatus records:

public sealed record BackgroundJobStatus(
string JobName,
string CronExpression,
bool IsEnabled,
DateTimeOffset? LastExecutedAt,
DateTimeOffset? NextExecutionAt,
int ConsecutiveFailures,
long DeadLetterCount,
string? LastError);

The endpoints are protected by the BackgroundJobs.Jobs.Manage permission. In production, grant this permission using one of two approaches:

{
"Authorization": {
"AdminRoles": ["admin", "ops-team"]
}
}

Manual triggers via TriggerNowAsync inject an X-Triggered-By header with the operator’s user ID. This is recorded in the job store for ISO 27001 traceability.