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.
Package structure
Section titled “Package structure”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
| Package | Role | Depends on |
|---|---|---|
Granit.BackgroundJobs | [RecurringJob] attribute, IBackgroundJobReader/Writer, in-memory store, Channel dispatcher | Granit.Core, Granit.Security, Granit.Timing, Granit.Guids |
Granit.BackgroundJobs.EntityFrameworkCore | BackgroundJobsDbContext, EfBackgroundJobStore (durable persistence) | Granit.BackgroundJobs, Granit.Persistence |
Granit.BackgroundJobs.Endpoints | 5 Minimal API endpoints with BackgroundJobs.Jobs.Manage permission | Granit.BackgroundJobs, Granit.Authorization, Granit.Querying |
Granit.BackgroundJobs.Wolverine | CronSchedulerAgent (singleton), RecurringJobSchedulingMiddleware, DLQ inspector | Granit.BackgroundJobs, Granit.Wolverine |
Dependency graph
Section titled “Dependency graph”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.
[DependsOn( typeof(GranitBackgroundJobsEntityFrameworkCoreModule), typeof(GranitBackgroundJobsWolverineModule), typeof(GranitBackgroundJobsEndpointsModule))]public class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { context.Builder.AddGranitBackgroundJobsEntityFrameworkCore( options => options.UseNpgsql( context.Configuration.GetConnectionString("BackgroundJobs"))); }}{ "ConnectionStrings": { "BackgroundJobs": "Host=db;Database=myapp;Username=app;Password=..." }}Route registration in Program.cs:
app.MapBackgroundJobsEndpoints();[DependsOn(typeof(GranitBackgroundJobsEntityFrameworkCoreModule))]public class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { context.Builder.AddGranitBackgroundJobsEntityFrameworkCore( options => options.UseNpgsql( context.Configuration.GetConnectionString("BackgroundJobs"))); }}Jobs persist in the database but dispatch uses in-process Channel<T>. Scheduling does not
survive process restarts and is not cluster-safe. Suitable for single-instance deployments.
Declaring a recurring job
Section titled “Declaring a recurring job”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); }}RecurringJobAttribute
Section titled “RecurringJobAttribute”[RecurringJob(string cronExpression, string name)]| Parameter | Description |
|---|---|
cronExpression | Standard 5-field or 6-field (with seconds) cron expression, parsed by Cronos |
name | Unique, stable identifier (kebab-case). Used as primary key in the persistent store |
Common cron expressions:
| Expression | Schedule |
|---|---|
0 * * * * | Every hour at minute 0 |
0 2 * * * | Daily at 02:00 UTC |
0 0 * * 1 | Every 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) |
Job discovery and seeding
Section titled “Job discovery and seeding”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
CronExpressionandMessageTypeupdated. Administrative state (IsEnabled,TriggeredBy, failure counters) is preserved across deployments.
BackgroundJobDefinition entity
Section titled “BackgroundJobDefinition entity”The persistent administrative record for each recurring job:
| Property | Type | Description |
|---|---|---|
Id | Guid | Primary key (sequential GUID) |
JobName | string (max 200) | Unique identifier matching [RecurringJob] name |
MessageType | string (max 500) | Assembly-qualified CLR type of the message |
CronExpression | string (max 100) | Cron schedule (5 or 6 fields) |
IsEnabled | bool | Active state (false = paused) |
LastExecutedAt | DateTimeOffset? | UTC timestamp of last execution start |
NextExecutionAt | DateTimeOffset? | UTC timestamp of next scheduled execution |
ConsecutiveFailureCount | int | Failures since last success (reset on success) |
LastErrorMessage | string? (max 2000) | Error from last failure (null on success) |
TriggeredBy | string? (max 450) | UserId of manual trigger operator (ISO 27001 audit) |
EF Core table: scheduling_background_jobs with a unique index on JobName.
CQRS reader/writer interfaces
Section titled “CQRS reader/writer interfaces”IBackgroundJobReader
Section titled “IBackgroundJobReader”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).
IBackgroundJobWriter
Section titled “IBackgroundJobWriter”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);}| Method | Behavior |
|---|---|
PauseAsync | Sets IsEnabled = false. Current execution completes; rescheduling is skipped. Emits BackgroundJobPaused domain event |
ResumeAsync | Sets IsEnabled = true and immediately schedules the next cron occurrence. Emits BackgroundJobResumed domain event |
TriggerNowAsync | Dispatches the job message immediately. Propagates caller identity via X-Triggered-By header for audit |
Store-level interfaces
Section titled “Store-level interfaces”Low-level persistence (internal use by the framework, not typically consumed by application code):
| Interface | Role |
|---|---|
IBackgroundJobStoreReader | FindAsync, GetEnabledJobsAsync, GetAllJobsAsync |
IBackgroundJobStoreWriter | SeedJobsAsync, RecordExecutionStartAsync, RecordNextExecutionAsync, RecordExecutionFailureAsync, SetEnabledAsync, SetTriggeredByAsync |
Scheduling flow
Section titled “Scheduling flow”Without Wolverine (in-process Channel)
Section titled “Without Wolverine (in-process Channel)”sequenceDiagram
participant Seed as SeedService
participant Sched as ChannelCronSchedulerService
participant Ch as Channel<T>
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.
With Wolverine (durable outbox)
Section titled “With Wolverine (durable outbox)”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.
RecurringJobSchedulingMiddleware
Section titled “RecurringJobSchedulingMiddleware”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-Byenvelope 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
NextExecutionAtin 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:
| Component | Without Wolverine | With Wolverine |
|---|---|---|
IBackgroundJobDispatcher | ChannelBackgroundJobDispatcher (in-memory) | WolverineBackgroundJobDispatcher (IMessageBus) |
| Scheduler | ChannelCronSchedulerService (BackgroundService) | CronSchedulerAgent (SingularAgent) |
IDeadLetterQueueInspector | NullDeadLetterQueueInspector (returns 0) | WolverineDeadLetterQueueInspector (IMessageStore) |
| Rescheduling | BackgroundJobWorker (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().
Endpoints
Section titled “Endpoints”Five Minimal API endpoints protected by the BackgroundJobs.Jobs.Manage permission:
| Method | Route | Handler | Response |
|---|---|---|---|
GET | /{prefix} | List all jobs (paginated) | 200 OK with PagedResult<BackgroundJobStatus> |
GET | /{prefix}/{name} | Get job by name | 200 OK / 404 Not Found |
POST | /{prefix}/{name}/pause | Pause a job | 204 No Content / 404 Not Found |
POST | /{prefix}/{name}/resume | Resume a paused job | 204 No Content / 404 Not Found |
POST | /{prefix}/{name}/trigger | Trigger immediate execution | 202 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";});Permission model
Section titled “Permission model”The BackgroundJobs.Jobs.Manage permission grants access to all five endpoints.
It integrates with the Granit RBAC pipeline:
AlwaysAllowbypass (dev/test:GranitAuthorizationOptions.AlwaysAllow = true)- Admin role bypass (
GranitAuthorizationOptions.AdminRoles) - 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)
Domain events
Section titled “Domain events”| Event | Emitted 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.
Observability
Section titled “Observability”The module registers a Granit.BackgroundJobs ActivitySource for distributed tracing.
Manual triggers create a backgroundjobs.trigger span with tags:
| Tag | Value |
|---|---|
backgroundjobs.job_name | Job name |
backgroundjobs.triggered_by | UserId or "system" |
When Granit.Observability is loaded, the activity source is auto-discovered via
GranitActivitySourceRegistry.
Configuration reference
Section titled “Configuration reference”{ "BackgroundJobs": { "Mode": "Durable", "ConnectionString": "Host=db;Database=myapp;Username=app;Password=..." }, "BackgroundJobsEndpoints": { "RoutePrefix": "background-jobs", "RequiredRole": "granit-background-jobs-admin", "TagName": "Background Jobs" }}| Property | Default | Description |
|---|---|---|
BackgroundJobs.Mode | InMemory | InMemory (no DB) or Durable (EF Core) |
BackgroundJobs.ConnectionString | — | Required 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 |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitBackgroundJobsModule, GranitBackgroundJobsEntityFrameworkCoreModule, GranitBackgroundJobsEndpointsModule, GranitBackgroundJobsWolverineModule | — |
| Attribute | [RecurringJob] | Granit.BackgroundJobs |
| CQRS | IBackgroundJobReader, IBackgroundJobWriter | Granit.BackgroundJobs |
| Store | IBackgroundJobStoreReader, IBackgroundJobStoreWriter | Granit.BackgroundJobs |
| Domain | BackgroundJobDefinition, BackgroundJobStatus, RecurringJobRegistration, JobStoreMode | Granit.BackgroundJobs |
| Events | BackgroundJobPaused, BackgroundJobResumed | Granit.BackgroundJobs |
| Abstractions | IBackgroundJobDispatcher, IDeadLetterQueueInspector | Granit.BackgroundJobs |
| Headers | BackgroundJobHeaders (X-Triggered-By) | Granit.BackgroundJobs |
| Options | BackgroundJobsOptions, BackgroundJobsEndpointsOptions | — |
| Permissions | BackgroundJobsPermissions.Jobs.Manage | Granit.BackgroundJobs.Endpoints |
| Middleware | RecurringJobSchedulingMiddleware | Granit.BackgroundJobs.Wolverine |
| Extensions | AddGranitBackgroundJobs(), AddGranitBackgroundJobsEntityFrameworkCore(), MapBackgroundJobsEndpoints() | — |
See also
Section titled “See also”- Wolverine module — Transactional outbox, context propagation, retry policy
- Persistence module —
ApplyGranitConventions, isolated DbContext pattern - Security module —
ICurrentUserServicefor audit trail propagation - Core module —
IDomainEvent,Entity, module system - API Reference (auto-generated from XML docs)