Skip to content

Granit.BackgroundJobs

Granit.BackgroundJobs provides a declarative, attribute-driven recurring job system built on Cronos cron parsing. Decorate a message class with [RecurringJob], write a handler, and the framework handles scheduling, persistence, pause/resume, manual trigger, failure tracking, and ISO 27001 audit trail. By default, jobs dispatch through in-process Channel<T> — add the Wolverine package for durable outbox scheduling with cluster-safe singleton execution.

  • DirectoryGranit.BackgroundJobs/ Core: [RecurringJob] attribute, Cronos cron, CQRS interfaces, Channel dispatcher
    • Granit.BackgroundJobs.EntityFrameworkCore EF Core store (SQL Server / PostgreSQL)
    • Granit.BackgroundJobs.Endpoints Minimal API admin endpoints (RBAC)
    • Granit.BackgroundJobs.Wolverine Durable outbox, SingularAgent, atomic rescheduling middleware
PackageRoleDepends on
Granit.BackgroundJobs[RecurringJob] attribute, IBackgroundJobReader/Writer, in-memory store, Channel dispatcherGranit.Core, Granit.Security, Granit.Timing, Granit.Guids
Granit.BackgroundJobs.EntityFrameworkCoreBackgroundJobsDbContext, EfBackgroundJobStore (durable persistence)Granit.BackgroundJobs, Granit.Persistence
Granit.BackgroundJobs.Endpoints5 Minimal API endpoints with BackgroundJobs.Jobs.Manage permissionGranit.BackgroundJobs, Granit.Authorization, Granit.Querying
Granit.BackgroundJobs.WolverineCronSchedulerAgent (singleton), RecurringJobSchedulingMiddleware, DLQ inspectorGranit.BackgroundJobs, Granit.Wolverine
graph TD
    BJ[Granit.BackgroundJobs] --> C[Granit.Core]
    BJ --> S[Granit.Security]
    BJ --> T[Granit.Timing]
    BJ --> G[Granit.Guids]
    EF[Granit.BackgroundJobs.EntityFrameworkCore] --> BJ
    EF --> P[Granit.Persistence]
    EP[Granit.BackgroundJobs.Endpoints] --> BJ
    EP --> A[Granit.Authorization]
    EP --> Q[Granit.Querying]
    W[Granit.BackgroundJobs.Wolverine] --> BJ
    W --> WM[Granit.Wolverine]
[DependsOn(typeof(GranitBackgroundJobsModule))]
public class AppModule : GranitModule { }

No database required. Jobs are stored in a ConcurrentDictionary. State is lost on restart.

Decorate a plain message class with [RecurringJob] and write a Wolverine-convention handler:

[RecurringJob("0 2 * * *", "nightly-appointment-cleanup")]
public sealed class NightlyAppointmentCleanupCommand;
public static class NightlyAppointmentCleanupHandler
{
public static async Task Handle(
NightlyAppointmentCleanupCommand command,
AppDbContext db,
IClock clock,
CancellationToken cancellationToken)
{
DateTimeOffset cutoff = clock.Now.AddDays(-90);
await db.Appointments
.Where(a => a.Status == AppointmentStatus.Cancelled
&& a.CancelledAt < cutoff)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
}
[RecurringJob(string cronExpression, string name)]
ParameterDescription
cronExpressionStandard 5-field or 6-field (with seconds) cron expression, parsed by Cronos
nameUnique, stable identifier (kebab-case). Used as primary key in the persistent store

Common cron expressions:

ExpressionSchedule
0 * * * *Every hour at minute 0
0 2 * * *Daily at 02:00 UTC
0 0 * * 1Every Monday at 00:00 UTC
*/5 * * * *Every 5 minutes
0 0 1 * *First day of each month at 00:00 UTC
0 */30 * * * *Every 30 seconds (6-field format)

On application startup, RecurringJobDiscovery scans the entry assembly (and any additional assemblies passed to AddGranitBackgroundJobs()) for types decorated with [RecurringJob]. Discovered jobs are seeded into the store via IBackgroundJobStoreWriter.SeedJobsAsync():

  • New jobs are inserted with IsEnabled = true.
  • Existing jobs have their CronExpression and MessageType updated. Administrative state (IsEnabled, TriggeredBy, failure counters) is preserved across deployments.

The persistent administrative record for each recurring job:

PropertyTypeDescription
IdGuidPrimary key (sequential GUID)
JobNamestring (max 200)Unique identifier matching [RecurringJob] name
MessageTypestring (max 500)Assembly-qualified CLR type of the message
CronExpressionstring (max 100)Cron schedule (5 or 6 fields)
IsEnabledboolActive state (false = paused)
LastExecutedAtDateTimeOffset?UTC timestamp of last execution start
NextExecutionAtDateTimeOffset?UTC timestamp of next scheduled execution
ConsecutiveFailureCountintFailures since last success (reset on success)
LastErrorMessagestring? (max 2000)Error from last failure (null on success)
TriggeredBystring? (max 450)UserId of manual trigger operator (ISO 27001 audit)

EF Core table: scheduling_background_jobs with a unique index on JobName.

Read-only monitoring interface returning immutable BackgroundJobStatus snapshots:

public interface IBackgroundJobReader
{
Task<IReadOnlyList<BackgroundJobStatus>> GetAllAsync(
CancellationToken cancellationToken = default);
Task<BackgroundJobStatus?> FindAsync(
string jobName, CancellationToken cancellationToken = default);
}

BackgroundJobStatus includes a DeadLetterCount field populated from Wolverine’s DLQ (returns 0 in in-memory mode).

Administrative operations with ISO 27001 audit trail:

public interface IBackgroundJobWriter
{
Task PauseAsync(string jobName, CancellationToken cancellationToken = default);
Task ResumeAsync(string jobName, CancellationToken cancellationToken = default);
Task TriggerNowAsync(string jobName, CancellationToken cancellationToken = default);
}
MethodBehavior
PauseAsyncSets IsEnabled = false. Current execution completes; rescheduling is skipped. Emits BackgroundJobPaused domain event
ResumeAsyncSets IsEnabled = true and immediately schedules the next cron occurrence. Emits BackgroundJobResumed domain event
TriggerNowAsyncDispatches the job message immediately. Propagates caller identity via X-Triggered-By header for audit

Low-level persistence (internal use by the framework, not typically consumed by application code):

InterfaceRole
IBackgroundJobStoreReaderFindAsync, GetEnabledJobsAsync, GetAllJobsAsync
IBackgroundJobStoreWriterSeedJobsAsync, RecordExecutionStartAsync, RecordNextExecutionAsync, RecordExecutionFailureAsync, SetEnabledAsync, SetTriggeredByAsync
sequenceDiagram
    participant Seed as SeedService
    participant Sched as ChannelCronSchedulerService
    participant Ch as Channel&lt;T&gt;
    participant Worker as BackgroundJobWorker
    participant Handler as Job Handler
    participant Store as IBackgroundJobStoreWriter

    Seed->>Store: SeedJobsAsync(registrations)
    Sched->>Store: GetEnabledJobsAsync()
    Sched->>Ch: ScheduleAsync(message, nextTime)
    Note over Sched,Ch: Task.Delay until scheduled time
    Ch->>Worker: ReadAllAsync()
    Worker->>Store: RecordExecutionStartAsync()
    Worker->>Handler: HandleAsync(message, ct)
    Handler-->>Worker: Success
    Worker->>Store: RecordNextExecutionAsync()
    Worker->>Ch: ScheduleAsync(nextMessage, nextTime)

The ChannelCronSchedulerService is a BackgroundService that schedules the first occurrence of each enabled job on startup. After each successful handler execution, BackgroundJobWorker computes and schedules the next occurrence. This does not survive process restarts and does not guarantee singleton execution across nodes.

sequenceDiagram
    participant Agent as CronSchedulerAgent
    participant Bus as IMessageBus
    participant Outbox as PostgreSQL Outbox
    participant MW as RecurringJobSchedulingMiddleware
    participant Handler as Job Handler
    participant Store as IBackgroundJobStoreWriter

    Note over Agent: SingularAgent — one node only
    Agent->>Store: GetEnabledJobsAsync()
    Agent->>Bus: ScheduleAsync(message, nextTime)
    Bus->>Outbox: INSERT scheduled message
    Note over Outbox: Wolverine dispatches at scheduled time
    Outbox->>MW: BeforeAsync(envelope)
    MW->>Store: RecordExecutionStartAsync()
    MW->>Handler: Handle(message)
    Handler-->>MW: Success
    MW->>MW: AfterAsync(envelope, context)
    MW->>Store: RecordNextExecutionAsync()
    MW->>Bus: context.ScheduleAsync(nextMessage, nextTime)
    Note over MW,Bus: Same DB transaction — atomic

CronSchedulerAgent (cluster-safe singleton)

Section titled “CronSchedulerAgent (cluster-safe singleton)”

When Granit.BackgroundJobs.Wolverine is loaded, the ChannelCronSchedulerService is replaced by a Wolverine SingularAgent (URI: granit-background-jobs://singleton). Wolverine guarantees that exactly one node in the cluster runs this agent at a time, preventing duplicate scheduling on multi-node startup.

Anti-doublon guarantee: before scheduling a job, the agent checks whether BackgroundJobDefinition.NextExecutionAt is already in the future. If it is, the job is already scheduled in the Outbox — no duplicate message is created.

Wolverine middleware automatically injected onto all handler chains whose message type carries [RecurringJob]. Never applied manually.

Before handler execution:

  • Records execution start time in the store
  • Reads the X-Triggered-By envelope header and persists it for ISO 27001 audit

After handler execution:

  • Computes the next cron occurrence
  • Calls context.ScheduleAsync() inside the same database transaction as the handler
  • Updates NextExecutionAt in the store

Atomicity guarantee: the next scheduled message is inserted in the Outbox within the same transaction as the handler’s business data. If the node crashes before commit, Wolverine redelivers the current message — the “next” message was never inserted, so no duplicate exists.

Wolverine-optional pattern (Channel fallback)

Section titled “Wolverine-optional pattern (Channel fallback)”

Wolverine is not required to use Granit.BackgroundJobs. The core package registers in-process Channel<T> implementations by default:

ComponentWithout WolverineWith Wolverine
IBackgroundJobDispatcherChannelBackgroundJobDispatcher (in-memory)WolverineBackgroundJobDispatcher (IMessageBus)
SchedulerChannelCronSchedulerService (BackgroundService)CronSchedulerAgent (SingularAgent)
IDeadLetterQueueInspectorNullDeadLetterQueueInspector (returns 0)WolverineDeadLetterQueueInspector (IMessageStore)
ReschedulingBackgroundJobWorker (in-process, post-handler)RecurringJobSchedulingMiddleware (atomic, in-transaction)

When GranitBackgroundJobsWolverineModule is loaded, it replaces all Channel-based registrations with Wolverine-backed implementations via ServiceDescriptor.Replace().

Five Minimal API endpoints protected by the BackgroundJobs.Jobs.Manage permission:

MethodRouteHandlerResponse
GET/{prefix}List all jobs (paginated)200 OK with PagedResult<BackgroundJobStatus>
GET/{prefix}/{name}Get job by name200 OK / 404 Not Found
POST/{prefix}/{name}/pausePause a job204 No Content / 404 Not Found
POST/{prefix}/{name}/resumeResume a paused job204 No Content / 404 Not Found
POST/{prefix}/{name}/triggerTrigger immediate execution202 Accepted / 404 Not Found

Default route prefix: background-jobs. Customize via BackgroundJobsEndpointsOptions:

app.MapBackgroundJobsEndpoints(opts =>
{
opts.RoutePrefix = "admin/jobs";
opts.RequiredRole = "ops-team";
opts.TagName = "Job Administration";
});

The BackgroundJobs.Jobs.Manage permission grants access to all five endpoints. It integrates with the Granit RBAC pipeline:

  1. AlwaysAllow bypass (dev/test: GranitAuthorizationOptions.AlwaysAllow = true)
  2. Admin role bypass (GranitAuthorizationOptions.AdminRoles)
  3. Permission grant store query per role

In production, grant the permission via:

  • Add the role to GranitAuthorizationOptions.AdminRoles
  • Call IPermissionManagerWriter.SetAsync("BackgroundJobs.Jobs.Manage", "my-role", tenantId, true)
EventEmitted when
BackgroundJobPaused(Guid JobId, string JobName)Job is paused via IBackgroundJobWriter.PauseAsync
BackgroundJobResumed(Guid JobId, string JobName)Job is resumed via IBackgroundJobWriter.ResumeAsync

Both implement IDomainEvent and can be handled by Wolverine or in-process subscribers.

The module registers a Granit.BackgroundJobs ActivitySource for distributed tracing. Manual triggers create a backgroundjobs.trigger span with tags:

TagValue
backgroundjobs.job_nameJob name
backgroundjobs.triggered_byUserId or "system"

When Granit.Observability is loaded, the activity source is auto-discovered via GranitActivitySourceRegistry.

{
"BackgroundJobs": {
"Mode": "Durable",
"ConnectionString": "Host=db;Database=myapp;Username=app;Password=..."
},
"BackgroundJobsEndpoints": {
"RoutePrefix": "background-jobs",
"RequiredRole": "granit-background-jobs-admin",
"TagName": "Background Jobs"
}
}
PropertyDefaultDescription
BackgroundJobs.ModeInMemoryInMemory (no DB) or Durable (EF Core)
BackgroundJobs.ConnectionStringRequired when Mode = Durable
BackgroundJobsEndpoints.RoutePrefix"background-jobs"URL prefix for admin endpoints
BackgroundJobsEndpoints.RequiredRole"granit-background-jobs-admin"Role required for endpoint access
BackgroundJobsEndpoints.TagName"Background Jobs"OpenAPI tag name
CategoryKey typesPackage
ModuleGranitBackgroundJobsModule, GranitBackgroundJobsEntityFrameworkCoreModule, GranitBackgroundJobsEndpointsModule, GranitBackgroundJobsWolverineModule
Attribute[RecurringJob]Granit.BackgroundJobs
CQRSIBackgroundJobReader, IBackgroundJobWriterGranit.BackgroundJobs
StoreIBackgroundJobStoreReader, IBackgroundJobStoreWriterGranit.BackgroundJobs
DomainBackgroundJobDefinition, BackgroundJobStatus, RecurringJobRegistration, JobStoreModeGranit.BackgroundJobs
EventsBackgroundJobPaused, BackgroundJobResumedGranit.BackgroundJobs
AbstractionsIBackgroundJobDispatcher, IDeadLetterQueueInspectorGranit.BackgroundJobs
HeadersBackgroundJobHeaders (X-Triggered-By)Granit.BackgroundJobs
OptionsBackgroundJobsOptions, BackgroundJobsEndpointsOptions
PermissionsBackgroundJobsPermissions.Jobs.ManageGranit.BackgroundJobs.Endpoints
MiddlewareRecurringJobSchedulingMiddlewareGranit.BackgroundJobs.Wolverine
ExtensionsAddGranitBackgroundJobs(), AddGranitBackgroundJobsEntityFrameworkCore(), MapBackgroundJobsEndpoints()