Getting Started with Pragmatic.Jobs
This guide walks you through creating your first background job, from package installation to seeing it execute. By the end, you will have a recurring cleanup job running on a cron schedule with retry, timeout, and EF Core persistence.
Prerequisites
Section titled “Prerequisites”- .NET 10.0+
Pragmatic.JobspackagePragmatic.SourceGeneratoranalyzer referencePragmatic.Temporal(comes transitively)
Your project file should include:
<ItemGroup> <PackageReference Include="Pragmatic.Jobs" /> <ProjectReference Include="..\Pragmatic.SourceGenerator\...\Pragmatic.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /></ItemGroup>Step 1: Define a Recurring Job
Section titled “Step 1: Define a Recurring Job”Create a class that implements IJob and decorate it with [RecurringJob]. The class must be partial so the source generator can emit the invoker.
using Pragmatic.Jobs;using Pragmatic.Jobs.Attributes;
namespace MyApp.Infrastructure.Jobs;
/// <summary>/// Cleans up expired user sessions every day at 3 AM./// </summary>[RecurringJob("0 3 * * *", Id = "cleanup-expired-sessions", TimeZone = "Europe/Rome")][Retry(MaxAttempts = 2, Strategy = BackoffStrategy.Exponential, BaseDelayMs = 2000)][Timeout(TimeoutSeconds = 120)]public sealed partial class CleanupExpiredSessionsJob( AppDbContext db, ILogger<CleanupExpiredSessionsJob> logger) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { var cutoff = context.ScheduledAt.AddDays(-30);
var deleted = await db.Sessions .Where(s => s.ExpiresAt < cutoff) .ExecuteDeleteAsync(ct);
logger.LogInformation( "Cleaned up {Count} expired sessions older than {Cutoff}", deleted, cutoff); }}Key points:
partialis mandatory — diagnostic PRAG2502 warns if missing.IJobis the parameterless interface. UseIJob<T>if the job needs input data.JobContext.ScheduledAtgives you the scheduled execution time, notDateTime.UtcNow.- Primary constructor parameters are resolved from DI (the SG handles registration).
Cron Expression Quick Reference
Section titled “Cron Expression Quick Reference”| Expression | Schedule |
|---|---|
0 3 * * * | Daily at 3:00 AM |
0 0 * * 0 | Every Sunday at midnight |
*/15 * * * * | Every 15 minutes |
0 9 1 * * | First day of every month at 9:00 AM |
0 0 * * 1-5 | Every weekday at midnight |
Step 2: Configure the Host
Section titled “Step 2: Configure the Host”With Pragmatic.Composition (Recommended)
Section titled “With Pragmatic.Composition (Recommended)”await PragmaticApp.RunAsync(args, app =>{ app.UseJobs(jobs => { jobs.WithWorkerCount(2); // 2 concurrent worker tasks jobs.WithPollingInterval(5); // Poll every 5 seconds });});The SG auto-discovers your job classes and generates all registration code. No manual AddTransient<CleanupExpiredSessionsJob>() needed.
Without Composition (Standalone)
Section titled “Without Composition (Standalone)”var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPragmaticJobs(jobs =>{ jobs.WithWorkerCount(2); jobs.WithPollingInterval(5);});
// Register the background processing servicesbuilder.Services.AddJobProcessingServices();
var app = builder.Build();app.Run();Step 3: Verify the Generated Output
Section titled “Step 3: Verify the Generated Output”Build the project. The source generator produces five files in obj/GeneratedFiles/Pragmatic.SourceGenerator/:
CleanupExpiredSessionsJob.Invoker.g.cs # Retry loop + timeout + telemetry_Infra.Jobs.Registration.g.cs # DI registration_Infra.Jobs.TypeRegistry.g.cs # AOT-safe type mapping_Infra.Jobs.RecurringJobs.g.cs # Cron definition registration_Metadata.Jobs.g.cs # Host aggregation metadataIn Visual Studio, expand Dependencies > Analyzers > Pragmatic.SourceGenerator in Solution Explorer to inspect these files.
In Rider, look under Dependencies > Source Generators > Pragmatic.SourceGenerator.
If these files are missing, check that the <ProjectReference> has OutputItemType="Analyzer".
Step 4: Add a Delayed Job with Parameters
Section titled “Step 4: Add a Delayed Job with Parameters”Not every job runs on a cron schedule. For jobs triggered by business events, use [Job] and schedule them programmatically.
Define the Job
Section titled “Define the Job”using Pragmatic.Jobs;using Pragmatic.Jobs.Attributes;
namespace MyApp.Infrastructure.Jobs;
public record SendReminderParams(Guid ReservationId, Guid GuestId, string GuestEmail, DateTimeOffset CheckIn);
[Job][Retry(MaxAttempts = 3, Strategy = BackoffStrategy.ExponentialWithJitter, BaseDelayMs = 500)]public sealed partial class SendCheckInReminderJob( IEmailService email, ILogger<SendCheckInReminderJob> logger) : IJob<SendReminderParams>{ public async Task ExecuteAsync(SendReminderParams p, JobContext context, CancellationToken ct) { logger.LogInformation( "Sending check-in reminder to {Email} for reservation {Id}", p.GuestEmail, p.ReservationId);
await email.SendAsync( p.GuestEmail, $"Reminder: your check-in is at {p.CheckIn:g}", $"Dear guest, your reservation {p.ReservationId} check-in is tomorrow.", ct); }}Schedule the Job
Section titled “Schedule the Job”Inject IJobScheduler and schedule when a reservation is confirmed:
public class ReservationConfirmedHandler(IJobScheduler scheduler){ public async Task HandleAsync(ReservationConfirmed evt, CancellationToken ct) { // Schedule reminder 24 hours before check-in var delay = evt.CheckIn - DateTimeOffset.UtcNow - TimeSpan.FromHours(24); if (delay > TimeSpan.Zero) { await scheduler.ScheduleAsync<SendCheckInReminderJob, SendReminderParams>( new SendReminderParams(evt.ReservationId, evt.GuestId, evt.GuestEmail, evt.CheckIn), delay: delay, correlationId: $"reservation-{evt.ReservationId}", ct: ct); } }}Scheduling methods:
| Method | When the job runs |
|---|---|
ScheduleAsync<TJob>() | Immediately (fire-and-forget) |
ScheduleAsync<TJob>(delay: ...) | After the specified delay |
ScheduleAtAsync<TJob>(scheduledFor: ...) | At the specified time |
CancelAsync(jobId) | Cancels a pending job |
Step 5: Add EF Core Persistence for Production
Section titled “Step 5: Add EF Core Persistence for Production”By default, jobs are stored in memory — fine for development, but lost on restart. For production, switch to EF Core:
Install the Package
Section titled “Install the Package”dotnet add package Pragmatic.Jobs.EFCoreEnable in Configuration
Section titled “Enable in Configuration”await PragmaticApp.RunAsync(args, app =>{ app.UseJobs(jobs => { jobs.WithWorkerCount(4); jobs.WithPollingInterval(5); jobs.WithLeaseTime(300); // 5 minute lease jobs.WithBatchSize(10); // Fetch 10 jobs per poll jobs.UseEfCore(); // Use database instead of memory });});This replaces:
InMemoryJobStorewithEfCoreJobStoreInMemoryRecurringJobStorewithEfCoreRecurringJobStore
Database Tables
Section titled “Database Tables”EF Core creates two tables:
| Table | Purpose |
|---|---|
__Jobs | Job instances with status, lease, retry tracking |
__RecurringJobs | Recurring definitions with next-run timestamps |
Distributed Locking
Section titled “Distributed Locking”With EF Core, multiple app instances can safely process jobs. The lease mechanism uses optimistic SQL — only one worker acquires each job. If a worker crashes, the lease expires and another worker picks it up.
Step 6: Add a Continuation Chain
Section titled “Step 6: Add a Continuation Chain”When one job must run after another succeeds, declare it with [Continuation<TNextJob>]:
[Job][Retry(MaxAttempts = 3)][Continuation<SendInvoiceEmailJob>]public sealed partial class GenerateInvoiceJob( IBillingService billing) : IJob<InvoiceParams>{ public async Task ExecuteAsync(InvoiceParams p, JobContext context, CancellationToken ct) => await billing.GenerateAsync(p.ReservationId, p.Amount, p.Currency, ct); // On success, SendInvoiceEmailJob is automatically enqueued}
[Job][Retry(MaxAttempts = 2)]public sealed partial class SendInvoiceEmailJob( IEmailService email) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) => await email.SendInvoiceAsync(context.CorrelationId!, ct); // Uses CorrelationId to find the invoice}The SG validates at compile time:
PRAG2505if the continuation type does not implementIJoborIJob<T>.PRAG2506if the chain creates a cycle (A->B->A).
What Happens at Runtime
Section titled “What Happens at Runtime”Here is the lifecycle for the recurring CleanupExpiredSessionsJob:
- Startup: The SG-generated
RegisterRecurringJobs()upserts the cron definition intoIRecurringJobStore. - Every 5 seconds:
RecurringJobSchedulerServicepolls for due recurring jobs. - At 3:00 AM Rome time: The cron expression matches. A
JobInstanceis enqueued withStatus = PendingandScheduledFor = now. - Next poll cycle:
JobProcessorServicefinds the pending job. A worker acquires the lease. - Execution: The generated invoker wraps
ExecuteAsyncwith a 120-second timeout and up to 2 retry attempts with exponential backoff. - Success: The job is marked
Completed. Metrics are emitted (pragmatic.jobs.completed,pragmatic.jobs.duration). - Failure: If all retries are exhausted, the job is marked
Failed. Thepragmatic.jobs.failedcounter increments.
For the delayed SendCheckInReminderJob:
- Business event: A reservation is confirmed. Your handler calls
scheduler.ScheduleAsync(...). - Job created: A
JobInstanceis stored withScheduledFor = now + delayandStatus = Pending. - When due:
JobProcessorServicepicks it up (it only fetches jobs whereScheduledFor <= now). - Execution: The generated invoker deserializes
SendReminderParamsfrom JSON, callsExecuteAsync, and handles retry/telemetry.
Next Steps
Section titled “Next Steps”- Read Architecture and Core Concepts for the full pipeline details and decision tree.
- Read Common Mistakes to avoid the most frequent issues.
- Read Troubleshooting for diagnostic IDs and debugging guidance.
- Explore the Showcase examples:
NoShowDetectionJobandSendCheckInReminderJobinexamples/showcase/src/Showcase.Booking/Infrastructure/Jobs/.