Common Mistakes
These are the most common issues developers encounter when using Pragmatic.Jobs. Each section shows the wrong approach, the correct approach, and explains why.
1. Forgetting partial on the Job Class
Section titled “1. Forgetting partial on the Job Class”Wrong:
[RecurringJob("0 2 * * *", Id = "daily-report")]public sealed class DailyReportJob(IReportService reports) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) => await reports.GenerateDailyAsync(context.ScheduledAt.Date, ct);}Compile result: Warning PRAG2502 — “Type ‘DailyReportJob’ is decorated with [Job]/[RecurringJob] but is not declared as partial.”
The code compiles but the generated invoker cannot merge into your class as a nested type. The SG falls back to a standalone invoker pattern, which is less efficient and loses some integration benefits.
Right:
[RecurringJob("0 2 * * *", Id = "daily-report")]public sealed partial class DailyReportJob(IReportService reports) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) => await reports.GenerateDailyAsync(context.ScheduledAt.Date, ct);}Why: The source generator emits a nested Invoker class inside a partial declaration of your job class. Without partial, the compiler cannot merge the two declarations. Always include partial on every job class.
2. Wrong Cron Expression Format
Section titled “2. Wrong Cron Expression Format”Wrong:
// 6-part (seconds) -- not supported[RecurringJob("0 0 2 * * *", Id = "daily-report")]public sealed partial class DailyReportJob : IJob { ... }
// Missing a field (only 4 parts)[RecurringJob("0 2 * *", Id = "daily-report")]public sealed partial class DailyReportJob : IJob { ... }
// Empty string[RecurringJob("", Id = "daily-report")]public sealed partial class DailyReportJob : IJob { ... }Compile result: Error PRAG2501 — “[RecurringJob] on ‘DailyReportJob’ has an empty or missing cron expression.”
Right:
// Standard 5-part: minute hour day-of-month month day-of-week[RecurringJob("0 2 * * *", Id = "daily-report")] // Daily at 2:00 AMpublic sealed partial class DailyReportJob : IJob { ... }Quick reference:
┌─── Minute (0-59)│ ┌─── Hour (0-23)│ │ ┌─── Day of month (1-31)│ │ │ ┌─── Month (1-12)│ │ │ │ ┌─── Day of week (0-6, Sun=0)│ │ │ │ │* * * * *Common expressions:
| Expression | Schedule |
|---|---|
0 2 * * * | Daily at 2:00 AM |
*/15 * * * * | Every 15 minutes |
0 0 * * 0 | Every Sunday at midnight |
0 9 1 * * | First of every month at 9:00 AM |
0 0 * * 1-5 | Every weekday at midnight |
3. Not Implementing IJob or IJob
Section titled “3. Not Implementing IJob or IJob”Wrong:
[Job]public sealed partial class SendReminderJob{ public async Task ExecuteAsync(ReminderParams p, CancellationToken ct) { // Looks like a job, but doesn't implement the interface }}Compile result: Error PRAG2500 — “Type ‘SendReminderJob’ is decorated with [Job]/[RecurringJob] but does not implement IJob or IJob
Right:
[Job]public sealed partial class SendReminderJob : IJob<ReminderParams>{ public async Task ExecuteAsync(ReminderParams p, JobContext context, CancellationToken ct) { // Now implements the correct interface }}Why: The SG validates that every class decorated with [Job] or [RecurringJob] implements one of the two job interfaces. This is an error, not a warning — the code will not compile. Note also that the ExecuteAsync signature must match the interface: IJob requires (JobContext, CancellationToken) and IJob<T> requires (T, JobContext, CancellationToken).
4. Duplicate Recurring Job IDs
Section titled “4. Duplicate Recurring Job IDs”Wrong:
[RecurringJob("0 2 * * *", Id = "daily-cleanup")]public sealed partial class CleanupSessionsJob : IJob { ... }
[RecurringJob("0 3 * * *", Id = "daily-cleanup")] // Same ID!public sealed partial class CleanupLogsJob : IJob { ... }Compile result: Error PRAG2503 — “Recurring job ID ‘daily-cleanup’ is used by both ‘CleanupSessionsJob’ and ‘CleanupLogsJob’.”
Right:
[RecurringJob("0 2 * * *", Id = "cleanup-sessions")]public sealed partial class CleanupSessionsJob : IJob { ... }
[RecurringJob("0 3 * * *", Id = "cleanup-logs")]public sealed partial class CleanupLogsJob : IJob { ... }Why: The recurring job ID is the primary key in __RecurringJobs. Two jobs with the same ID would overwrite each other’s definitions. The SG catches this at compile time.
Tip: If you omit the Id property, the SG generates one from the class name by converting to kebab-case and stripping the “Job” suffix: CleanupSessionsJob becomes cleanup-sessions. This auto-generated ID is usually sufficient and avoids accidental duplicates.
5. Retry MaxAttempts <= 0
Section titled “5. Retry MaxAttempts <= 0”Wrong:
[Job][Retry(MaxAttempts = 0)] // Zero retriespublic sealed partial class SendNotificationJob : IJob<NotificationParams> { ... }
[Job][Retry(MaxAttempts = -1)] // Negative retriespublic sealed partial class SendNotificationJob : IJob<NotificationParams> { ... }Compile result: Error PRAG2504 — “[Retry] on ‘SendNotificationJob’ has MaxAttempts = 0. MaxAttempts must be greater than 0.”
Right:
[Job][Retry(MaxAttempts = 1)] // Executes once, no retries (1 attempt total)public sealed partial class SendNotificationJob : IJob<NotificationParams> { ... }
[Job][Retry(MaxAttempts = 3)] // Executes once + up to 2 retries (3 attempts total)public sealed partial class SendNotificationJob : IJob<NotificationParams> { ... }Why: MaxAttempts means the total number of execution attempts, including the first one. A value of 0 or negative makes no sense — the job would never execute. The SG requires MaxAttempts > 0.
Note: If you want no retry at all, simply omit the [Retry] attribute. The DefaultMaxRetries in JobsOptions (default: 1) will apply, meaning one attempt with no retry.
6. Continuation Cycle (A -> B -> A)
Section titled “6. Continuation Cycle (A -> B -> A)”Wrong:
[Job][Continuation<SendInvoiceEmailJob>]public sealed partial class GenerateInvoiceJob : IJob<InvoiceParams> { ... }
[Job][Continuation<GenerateInvoiceJob>] // Cycle!public sealed partial class SendInvoiceEmailJob : IJob { ... }Compile result: Error PRAG2506 — “Job ‘GenerateInvoiceJob’ creates a cycle in continuation chain: GenerateInvoiceJob -> SendInvoiceEmailJob -> GenerateInvoiceJob.”
The SG performs DAG traversal on the continuation graph and detects cycles of any length (A->B->A, A->B->C->A, etc.).
Right:
[Job][Continuation<SendInvoiceEmailJob>]public sealed partial class GenerateInvoiceJob : IJob<InvoiceParams> { ... }
[Job] // No continuation -- this is the end of the chainpublic sealed partial class SendInvoiceEmailJob : IJob { ... }Why: A cycle would cause infinite job scheduling. The SG validates the entire continuation graph at compile time to prevent this.
7. Not Configuring EF Core for Production
Section titled “7. Not Configuring EF Core for Production”Wrong:
// Using defaults -- InMemory storeawait PragmaticApp.RunAsync(args, app =>{ app.UseJobs();});This works fine in development, but in production:
- All pending jobs are lost when the process restarts.
- No distributed locking — multiple instances process the same job.
- Recurring job next-run timestamps are not persisted.
Right:
await PragmaticApp.RunAsync(args, app =>{ app.UseJobs(jobs => { jobs.UseEfCore(); });});Why: The in-memory stores (InMemoryJobStore, InMemoryRecurringJobStore) are designed for development and testing only. They provide no durability and no distributed locking. For any deployment beyond a single-instance development server, use UseEfCore() to enable database persistence with lease-based locking.
Tip: You can use environment-based configuration:
app.UseJobs(jobs =>{ jobs.WithWorkerCount(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development" ? 1 : 4);
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != "Development") jobs.UseEfCore();});8. Blocking Code in ExecuteAsync
Section titled “8. Blocking Code in ExecuteAsync”Wrong:
[RecurringJob("*/5 * * * *", Id = "sync-external-data")]public sealed partial class SyncExternalDataJob(HttpClient http) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { // Blocking call -- ties up the thread pool var response = http.GetStringAsync("https://api.example.com/data").Result;
// Long-running synchronous loop Thread.Sleep(5000);
// More blocking var result = SomeLibrary.ProcessSync(response); }}Right:
[RecurringJob("*/5 * * * *", Id = "sync-external-data")]public sealed partial class SyncExternalDataJob(HttpClient http) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { var response = await http.GetStringAsync("https://api.example.com/data", ct);
await Task.Delay(5000, ct); // If you must wait, do it async
// Offload CPU-bound work to the thread pool properly var result = await Task.Run(() => SomeLibrary.ProcessSync(response), ct); }}Why: JobProcessorService uses a SemaphoreSlim to limit concurrency to WorkerCount tasks. If a job blocks its thread with .Result, .Wait(), or Thread.Sleep(), it wastes one of the limited worker slots. All I/O should be await-ed, and the CancellationToken should be passed through to every async call so that timeouts and shutdown work correctly.
9. Ignoring the CancellationToken
Section titled “9. Ignoring the CancellationToken”Wrong:
[Job][Timeout(TimeoutSeconds = 30)]public sealed partial class GenerateReportJob(IReportService reports) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { // Ignores ct -- timeout has no effect await reports.GenerateAsync(context.ScheduledAt.Date); await Task.Delay(10000); // Not cancellable await reports.ExportToPdfAsync("report.pdf"); // Not cancellable }}Right:
[Job][Timeout(TimeoutSeconds = 30)]public sealed partial class GenerateReportJob(IReportService reports) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { await reports.GenerateAsync(context.ScheduledAt.Date, ct); await Task.Delay(10000, ct); await reports.ExportToPdfAsync("report.pdf", ct); }}Why: The [Timeout] attribute makes the SG generate a linked CancellationTokenSource with CancelAfter. But if your code never checks the token, the timeout has no effect. The job will keep running until it finishes or the process shuts down. Always pass ct to every await-ed operation.
10. Using [RecurringJob] on IJob
Section titled “10. Using [RecurringJob] on IJob”Subtle mistake:
[RecurringJob("0 2 * * *", Id = "send-daily-summary")]public sealed partial class SendDailySummaryJob : IJob<SummaryParams>{ public async Task ExecuteAsync(SummaryParams p, JobContext context, CancellationToken ct) { // Where does SummaryParams come from on a recurring schedule? }}This technically compiles and generates code, but the SummaryParams will always be null at runtime because recurring jobs have no parameter source. The RecurringJobSchedulerService enqueues instances with ParametersJson = null.
Right for recurring: Use IJob (parameterless) and derive context from JobContext.ScheduledAt:
[RecurringJob("0 2 * * *", Id = "send-daily-summary")]public sealed partial class SendDailySummaryJob(ISummaryService summaries) : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { // Derive the date from the schedule var reportDate = context.ScheduledAt.Date; await summaries.GenerateAndSendAsync(reportDate, ct); }}Right for parametric: Use [Job] and schedule programmatically:
[Job]public sealed partial class SendDailySummaryJob : IJob<SummaryParams>{ public async Task ExecuteAsync(SummaryParams p, JobContext context, CancellationToken ct) { await summaries.GenerateAndSendAsync(p.ReportDate, p.RecipientEmail, ct); }}
// Schedule with parametersawait scheduler.ScheduleAsync<SendDailySummaryJob, SummaryParams>( new SummaryParams(DateTime.UtcNow.Date, "manager@company.com"));11. Not Passing CorrelationId in Continuation Chains
Section titled “11. Not Passing CorrelationId in Continuation Chains”Wrong:
// Scheduling without correlationIdawait scheduler.ScheduleAsync<GenerateInvoiceJob, InvoiceParams>( new InvoiceParams(reservationId, amount, "EUR"));
// In the continuation, CorrelationId is null[Job]public sealed partial class SendInvoiceEmailJob : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { var invoiceId = context.CorrelationId; // null! }}Right:
await scheduler.ScheduleAsync<GenerateInvoiceJob, InvoiceParams>( new InvoiceParams(reservationId, amount, "EUR"), correlationId: $"reservation-{reservationId}");
[Job]public sealed partial class SendInvoiceEmailJob : IJob{ public async Task ExecuteAsync(JobContext context, CancellationToken ct) { // CorrelationId is carried forward through the continuation chain var correlationId = context.CorrelationId; // "reservation-{guid}" }}Why: When a job has [Continuation<T>], the CorrelationId is automatically forwarded to the continuation job. But if the original job was scheduled without a correlationId, the continuation also gets null. Always provide a meaningful correlation ID when scheduling jobs that participate in continuation chains.
Summary Table
Section titled “Summary Table”| # | Mistake | Diagnostic | Severity |
|---|---|---|---|
| 1 | Missing partial | PRAG2502 | Warning |
| 2 | Invalid cron expression | PRAG2501 | Error |
| 3 | Not implementing IJob/IJob<T> | PRAG2500 | Error |
| 4 | Duplicate recurring job IDs | PRAG2503 | Error |
| 5 | MaxAttempts <= 0 | PRAG2504 | Error |
| 6 | Continuation cycle | PRAG2506 | Error |
| 7 | No EF Core in production | (runtime) | Data loss risk |
| 8 | Blocking code in ExecuteAsync | (runtime) | Thread pool starvation |
| 9 | Ignoring CancellationToken | (runtime) | Timeout ineffective |
| 10 | [RecurringJob] on IJob<T> | (runtime) | Null parameters |
| 11 | Missing CorrelationId in chains | (runtime) | Lost tracing context |