Skip to content

Background Jobs — Wolverine & Cron Scheduling

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, Granit.Users, 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.QueryEngine
Granit.BackgroundJobs.WolverineCronSchedulerAgent (singleton), RecurringJobSchedulingMiddleware, DLQ inspectorGranit.BackgroundJobs, Granit.Wolverine
graph TD
    BJ[Granit.BackgroundJobs] --> C[Granit]
    BJ --> S[Granit.Users]
    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.QueryEngine]
    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.

Background jobs follow a strict naming convention, parallel to events (*Event / *Eto):

AspectConvention
MarkerIBackgroundJob + [RecurringJob] attribute
Suffix*Job (e.g., OrphanBlobCleanupJob)
Record shape[RecurringJob("cron", "name")] sealed record XxxJob : IBackgroundJob
Handlerinternal static partial class XxxHandler with HandleAsync
LocationGranit.{Module}/Jobs/ (job + handler in same folder)
Job name{module-kebab}-{action-kebab} (e.g., "blob-storage-orphan-cleanup")

Architecture tests enforce:

  • All IBackgroundJob implementors must end with Job
  • All [RecurringJob]-decorated types must implement IBackgroundJob

Implement IBackgroundJob, decorate with [RecurringJob], and write a handler:

src/Granit.MyModule/Jobs/NightlyCleanupJob.cs
[RecurringJob("0 2 * * *", "my-module-nightly-cleanup")]
public sealed record NightlyCleanupJob : IBackgroundJob;
// src/Granit.MyModule/Jobs/NightlyCleanupHandler.cs
internal static partial class NightlyCleanupHandler
{
public static async Task HandleAsync(
NightlyCleanupJob job,
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 500 + suffix)Error from last failure, truncated to 500 chars to limit information disclosure (null on success)
TriggeredBystring? (max 450)UserId of manual trigger operator (ISO 27001 audit)

EF Core table: background_jobs_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 BackgroundJobPausedEvent domain event
ResumeAsyncSets IsEnabled = true and immediately schedules the next cron occurrence. Emits BackgroundJobResumedEvent 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 with split permissions — BackgroundJobs.Jobs.Read for read operations and BackgroundJobs.Jobs.Manage for mutations:

MethodRouteHandlerPermissionResponse
GET/{prefix}List all jobs (paginated)Jobs.Read200 OK with PagedResult<BackgroundJobStatus>
GET/{prefix}/{name}Get job by nameJobs.Read200 OK / 404 Not Found
POST/{prefix}/{name}/pausePause a jobJobs.Manage204 No Content / 404 Not Found
POST/{prefix}/{name}/resumeResume a paused jobJobs.Manage204 No Content / 404 Not Found
POST/{prefix}/{name}/triggerTrigger immediate executionJobs.Manage202 Accepted / 404 Not Found

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

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

Two permissions control access with least-privilege separation:

PermissionGrants
BackgroundJobs.Jobs.ReadView job status and metadata (GET endpoints)
BackgroundJobs.Jobs.ManagePause, resume, and trigger jobs (POST endpoints)

Both integrate 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 permissions via:

  • Add the role to GranitAuthorizationOptions.AdminRoles
  • Call IPermissionManagerWriter.SetAsync("BackgroundJobs.Jobs.Read", "my-role", tenantId, true)
  • Call IPermissionManagerWriter.SetAsync("BackgroundJobs.Jobs.Manage", "my-role", tenantId, true)
EventEmitted when
BackgroundJobPausedEvent(Guid JobId, string JobName)Job is paused via IBackgroundJobWriter.PauseAsync
BackgroundJobResumedEvent(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
MarkerIBackgroundJobGranit.BackgroundJobs
Attribute[RecurringJob]Granit.BackgroundJobs
CQRSIBackgroundJobReader, IBackgroundJobWriterGranit.BackgroundJobs
StoreIBackgroundJobStoreReader, IBackgroundJobStoreWriterGranit.BackgroundJobs
DomainBackgroundJobDefinition, BackgroundJobStatus, RecurringJobRegistration, JobStoreModeGranit.BackgroundJobs
EventsBackgroundJobPausedEvent, BackgroundJobResumedEventGranit.BackgroundJobs
AbstractionsIBackgroundJobDispatcher, IDeadLetterQueueInspectorGranit.BackgroundJobs
HeadersBackgroundJobHeaders (X-Triggered-By)Granit.BackgroundJobs
OptionsBackgroundJobsOptions, BackgroundJobsEndpointsOptions
PermissionsBackgroundJobsPermissions.Jobs.Read, BackgroundJobsPermissions.Jobs.ManageGranit.BackgroundJobs.Endpoints
MiddlewareRecurringJobSchedulingMiddlewareGranit.BackgroundJobs.Wolverine
ExtensionsAddGranitBackgroundJobs(), AddGranitBackgroundJobsEntityFrameworkCore(), MapGranitBackgroundJobs()

Use Granit.BackgroundJobs when:

  • You need recurring scheduled work (cron-based: nightly reports, weekly cleanups, hourly syncs)
  • Jobs must be durable — surviving restarts and distributed across cluster nodes
  • You need admin visibility — pause/resume/trigger jobs via endpoints, audit who did what
  • Jobs must propagate tenant context and user context (multi-tenant background processing)

Skip it when:

  • You need a one-off async task after an HTTP request (e.g., send a confirmation email) — use Wolverine’s IMessageBus.PublishAsync() directly
  • The work is fire-and-forget with no durability requirementTask.Run() or a hosted service is simpler
  • You need sub-second scheduling — cron’s minimum granularity is 1 second; for real-time use Wolverine message scheduling

Test job handlers as plain classes — no scheduler needed:

[Fact]
public async Task Cleanup_job_deletes_expired_records()
{
// Arrange
await using var context = CreateSqliteContext<AppDbContext>();
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
var handler = new ExpiredRecordCleanupJob(context, clock);
context.Records.Add(new Record { ExpiresAt = clock.Now.AddDays(-1) });
await context.SaveChangesAsync();
// Act
await handler.HandleAsync(new RunExpiredRecordCleanup());
// Assert
var remaining = await context.Records.CountAsync();
remaining.ShouldBe(0);
}

Error message truncation: BackgroundJobDefinition.RecordFailure() truncates error messages to 500 characters before persisting them. This prevents accidental information disclosure through the admin API or integration events. Job handlers should still avoid including PII in exception messages.

Type validation: CronSchedulerHelper.CreateMessage() validates that the resolved CLR type implements IBackgroundJob before instantiation, preventing arbitrary type activation from tampered database values.

Key material zeroization: The OpenIddictKeyRotationJob clears RSA private key bytes from memory via CryptographicOperations.ZeroMemory() immediately after encryption, minimizing the window for key material exposure through memory dumps.

GDPR deletion ordering: DeletionDeadlineEnforcerHandler publishes deletion events into the Wolverine outbox before marking the request as executed, ensuring deletion is never silently skipped on partial failure.

Granit.BackgroundJobs.Notifications ships notifications for the lifecycle events of this module. Tenants opt in via the notifications admin UI.

NotificationTriggerChannelsSeverity
jobs.recurring_failingBackgroundJobFailureThresholdExceededEto — emitted when a recurring job exceeds its consecutive-failure thresholdEmail, InAppWarning

Email templates ship in EN + FR; additional cultures are produced via the translation script (US #1311).