Skip to content

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.


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.


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 AM
public 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:

ExpressionSchedule
0 2 * * *Daily at 2:00 AM
*/15 * * * *Every 15 minutes
0 0 * * 0Every Sunday at midnight
0 9 1 * *First of every month at 9:00 AM
0 0 * * 1-5Every weekday at midnight

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).


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.


Wrong:

[Job]
[Retry(MaxAttempts = 0)] // Zero retries
public sealed partial class SendNotificationJob : IJob<NotificationParams> { ... }
[Job]
[Retry(MaxAttempts = -1)] // Negative retries
public 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.


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 chain
public 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.


Wrong:

// Using defaults -- InMemory store
await 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();
});

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.


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.


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 parameters
await 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 correlationId
await 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.


#MistakeDiagnosticSeverity
1Missing partialPRAG2502Warning
2Invalid cron expressionPRAG2501Error
3Not implementing IJob/IJob<T>PRAG2500Error
4Duplicate recurring job IDsPRAG2503Error
5MaxAttempts <= 0PRAG2504Error
6Continuation cyclePRAG2506Error
7No EF Core in production(runtime)Data loss risk
8Blocking code in ExecuteAsync(runtime)Thread pool starvation
9Ignoring CancellationToken(runtime)Timeout ineffective
10[RecurringJob] on IJob<T>(runtime)Null parameters
11Missing CorrelationId in chains(runtime)Lost tracing context