Roslyn Diagnostic Analyzers¶
NexJob ships with built-in compile-time Roslyn Diagnostic Analyzers bundled directly in the core NexJob package (analyzers/dotnet/cs/NexJob.Analyzers.dll). Installing NexJob automatically activates real-time guardrails in your IDE (Visual Studio, JetBrains Rider, VS Code) without requiring any extra package installation or setup.
The analyzers act as guardrails against common background job authoring bugs that the C# compiler cannot catch on its own.
Configuration & Severity¶
All NexJob analyzer rules default to Info severity (and specific rules such as NXJ011 and NXJ016 are opt-in, disabled by default).
Because default severity is Info:
- Diagnostics appear as suggestions in IDEs.
- Builds in the terminal or CI under
TreatWarningsAsErrors = truedo not fail by default.
Configuring in .editorconfig¶
To enforce rules as warnings/errors or enable opt-in rules, add configuration to your solution or project's .editorconfig:
[*.cs]
# Elevate all NexJob reliability or design rules to warning (breaks build under TreatWarningsAsErrors in CI/terminal)
dotnet_analyzer_diagnostic.category-Reliability.severity = warning
dotnet_analyzer_diagnostic.category-Design.severity = warning
# Elevate an individual rule
dotnet_diagnostic.NXJ001.severity = warning
# Enable opt-in rules (disabled by default)
dotnet_diagnostic.NXJ011.severity = info
dotnet_diagnostic.NXJ016.severity = info
# Disable a specific rule
dotnet_diagnostic.NXJ005.severity = none
Rules Index¶
| Rule ID | Category | Default Severity | Short Description |
|---|---|---|---|
| NXJ001 | Reliability | Info | Avoid blocking calls in job execution |
| NXJ002 | Reliability | Info | Avoid DateTime.Now in job execution |
| NXJ003 | Design | Info | Job class must be public and instantiable |
| NXJ004 | Reliability | Info | Propagate CancellationToken in job execution |
| NXJ005 | Reliability | Info | Avoid static mutable state in job classes |
| NXJ006 | Reliability | Info | Avoid fire-and-forget tasks in job execution |
| NXJ007 | Design | Info | Avoid direct service instantiation inside job |
| NXJ010 | Reliability | Info | Enqueue with deadlineAfter should specify bounded maxAttempts |
| NXJ011 | Design | Info (Opt-in) | Consider providing an idempotencyKey for background jobs |
| NXJ015 | Reliability | Info | Prevent recursive job continuation |
| NXJ016 | Reliability | Info (Opt-in) | Enqueue without explicit queue may cause cross-service prefix mismatch |
| NXJ017 | Reliability | Info | Avoid swallowing exceptions in job execution |
| NXJ018 | Reliability | Info | Avoid swallowing OperationCanceledException in job execution |
| NXJ019 | Reliability | Info | Invalid job attribute argument |
Rule Details¶
NXJ001¶
Title: Avoid blocking calls in job execution
Category: Reliability
Default Severity: Info
Background jobs execute inside worker threads managed by the dispatcher. Calling synchronous blocking APIs such as .Result, .Wait(), Thread.Sleep(), GetAwaiter().GetResult(), Task.WaitAll(...), or Task.WaitAny(...) blocks the executing thread, starving the .NET thread pool and preventing other jobs from progressing.
Example¶
public sealed class ExportReportJob : IJob
{
public Task ExecuteAsync(CancellationToken ct)
{
// Blocking call starves worker thread pool
var data = FetchDataAsync().GetAwaiter().GetResult();
Thread.Sleep(1000);
return Task.CompletedTask;
}
}
public sealed class ExportReportJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
var data = await FetchDataAsync(ct);
await Task.Delay(1000, ct);
}
}
NXJ002¶
Title: Avoid DateTime.Now in job execution
Category: Reliability
Default Severity: Info
DateTime.Now and DateTime.Today depend on local machine time zones and daylight saving time (DST) transitions. In distributed environments where workers run across multiple containers or servers in different regions, using local time produces inconsistent timestamps, corrupted audit histories, and race conditions. Use DateTime.UtcNow.
Example¶
public sealed class BillingJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
// Depends on local server timezone
var today = DateTime.Today;
await ProcessBillingAsync(today, ct);
}
}
public sealed class BillingJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
var nowUtc = DateTime.UtcNow;
await ProcessBillingAsync(nowUtc, ct);
}
}
NXJ003¶
Title: Job class must be public and instantiable
Category: Design
Default Severity: Info
NexJob instantiates jobs dynamically using dependency injection when worker slots pick them up. If a job class or record is internal, abstract, or lacks a public constructor, dependency injection fails at runtime when the job is dequeued.
Example¶
// Internal or abstract job cannot be resolved by DI at runtime
internal abstract class ProcessOrderJob : IJob
{
public abstract Task ExecuteAsync(CancellationToken ct);
}
public sealed class ProcessOrderJob : IJob
{
private readonly IOrderService _orderService;
public ProcessOrderJob(IOrderService orderService)
{
_orderService = orderService;
}
public async Task ExecuteAsync(CancellationToken ct)
{
await _orderService.ProcessAsync(ct);
}
}
NXJ004¶
Title: Propagate CancellationToken in job execution
Category: Reliability
Default Severity: Info
NexJob signals cancellation when a job reaches its deadline or when the host undergoes graceful shutdown. Omitting the available CancellationToken in async calls or passing CancellationToken.None prevents the runtime from terminating long-running operations promptly, causing jobs to hang during shutdown and requiring orphan recovery.
Example¶
public sealed class SyncDataJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
// ct is ignored; cannot be cancelled on graceful shutdown or deadline
await _client.DownloadUpdatesAsync();
await Task.Delay(2000);
}
}
public sealed class SyncDataJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
await _client.DownloadUpdatesAsync(ct);
await Task.Delay(2000, ct);
}
}
NXJ005¶
Title: Avoid static mutable state in job classes
Category: Reliability
Default Severity: Info
Worker nodes run multiple job instances concurrently in parallel threads and processes. Static mutable fields, collections (e.g. List<T>, Dictionary<K,V>), and static auto-properties are shared across all executions, causing data corruption and thread-safety bugs. Job state should be passed via parameters, stored in instance fields resolved via scoped DI, or persisted in storage.
Example¶
public sealed class ImportJob : IJob
{
// Shared across concurrent executions - race condition!
private static int _processedCount;
private static readonly List<string> _errors = new();
public async Task ExecuteAsync(CancellationToken ct)
{
_processedCount++;
_errors.Add("sample");
await Task.Yield();
}
}
public sealed class ImportJob : IJob
{
private readonly IMetricsCollector _metrics;
public ImportJob(IMetricsCollector metrics)
{
_metrics = metrics;
}
public async Task ExecuteAsync(CancellationToken ct)
{
await _metrics.IncrementProcessedAsync(ct);
}
}
NXJ006¶
Title: Avoid fire-and-forget tasks in job execution
Category: Reliability
Default Severity: Info
Background jobs must be fully awaited. Invoking Task.Run(...) or unawaited async operations without awaiting them causes the task to escape NexJob's execution pipeline. If an unawaited task throws an exception or takes longer to complete, the job may already be marked as succeeded, and errors will crash unobserved or escape retries and dead-letter handling.
Example¶
public sealed class NotificationJob : IJob
{
public Task ExecuteAsync(CancellationToken ct)
{
// Fire-and-forget task escapes dispatcher monitoring and error handling
_ = Task.Run(() => SendEmailAsync(), ct);
return Task.CompletedTask;
}
}
public sealed class NotificationJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
await SendEmailAsync(ct);
}
}
NXJ007¶
Title: Avoid direct service instantiation inside job
Category: Design
Default Severity: Info
Jobs execute inside a dedicated scoped dependency injection container. Instantiating services directly with new MyDbContext() or new HttpClient() bypasses lifetime management, connection pooling, and disposal. Inject required dependencies through the job's constructor instead.
Example¶
public sealed class CleanUpJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
// Bypasses DI scope, lifetime management, and connection pooling
using var db = new AppDbContext();
await db.PurgeOldRecordsAsync(ct);
}
}
public sealed class CleanUpJob : IJob
{
private readonly AppDbContext _db;
public CleanUpJob(AppDbContext db)
{
_db = db;
}
public async Task ExecuteAsync(CancellationToken ct)
{
await _db.PurgeOldRecordsAsync(ct);
}
}
NXJ010¶
Title: Enqueue with deadlineAfter should specify bounded maxAttempts
Category: Reliability
Default Severity: Info
When you set deadlineAfter without an explicit maxAttempts, the job uses the global attempt limit (10 by default) and keeps retrying, with growing delays, until it runs out of attempts or its deadline passes. A job whose deadline passes before an attempt starts ends as Expired: it never runs again and no dead-letter handler is called. Providing an explicit maxAttempts makes the limit part of the call, so a persistent failure ends as Failed (and reaches your dead-letter handler) instead of waiting for the deadline.
Example¶
// Uses the global attempt limit and may end as Expired instead of Failed
await scheduler.EnqueueAsync<SyncJob>(
deadlineAfter: TimeSpan.FromHours(1),
cancellationToken: cancellationToken);
// Bounded to 3 attempts within the 1-hour window
await scheduler.EnqueueAsync<SyncJob>(
maxAttempts: 3,
deadlineAfter: TimeSpan.FromHours(1),
cancellationToken: cancellationToken);
NXJ011¶
Title: Consider providing an idempotencyKey for background jobs
Category: Design
Default Severity: Info (Opt-in)
In distributed systems, message triggers, network retries, and producer replays can lead to the same job being scheduled more than once. Providing an idempotencyKey (such as a broker message ID or business transaction ID) enables NexJob to deduplicate requests automatically.
Note
NXJ011 is disabled by default to avoid noise in codebases where not every job requires deduplication. Enable it via .editorconfig when desired.
Example¶
// Can enqueue duplicate records if the HTTP request or message is retried
await scheduler.EnqueueAsync<ChargeCustomerJob, ChargePayload>(
input,
cancellationToken: cancellationToken);
await scheduler.EnqueueAsync<ChargeCustomerJob, ChargePayload>(
input,
idempotencyKey: $"payment-{input.PaymentId}",
cancellationToken: cancellationToken);
NXJ015¶
Title: Prevent recursive job continuation
Category: Reliability
Default Severity: Info
Directly scheduling the same job type from inside its own ExecuteAsync method without termination conditions creates infinite continuation loops that flood queues and bypass scheduling cadence controls. For repeating tasks, prefer Recurring Jobs.
Example¶
public sealed class PollingJob : IJob
{
private readonly IScheduler _scheduler;
public PollingJob(IScheduler scheduler) => _scheduler = scheduler;
public async Task ExecuteAsync(CancellationToken ct)
{
await PollAsync(ct);
// Infinite recursive loop
await _scheduler.EnqueueAsync<PollingJob>(cancellationToken: ct);
}
}
// Register as a recurring job instead:
builder.Services.AddNexJob(o =>
{
o.AddRecurringJob<PollingJob>("polling-job", CronExpressions.EveryMinute);
});
NXJ016¶
Title: Enqueue without explicit queue may cause cross-service prefix mismatch
Category: Reliability
Default Severity: Info (Opt-in)
In multi-service setups where different services share the same database with distinct QueuePrefix configurations, enqueuing without an explicit queue parameter places the job in the producer's default queue. Consumer worker services polling a different prefixed queue will never pick it up. See the default queue and prefix.
Note
NXJ016 is disabled by default. Enable it in multi-service distributed architectures via .editorconfig.
Example¶
// Omitting queue targets producer's default queue, consumer may not listen to it
await scheduler.EnqueueAsync<ExternalProcessingJob, JobPayload>(
input,
cancellationToken: cancellationToken);
await scheduler.EnqueueAsync<ExternalProcessingJob, JobPayload>(
input,
queue: "processing-service",
cancellationToken: cancellationToken);
NXJ017¶
Title: Avoid swallowing exceptions in job execution
Category: Reliability
Default Severity: Info
Catching general exceptions (catch (Exception) or untyped catch) inside IJob.ExecuteAsync without rethrowing them causes the job to finish as succeeded in NexJob storage. When this happens:
- The job is marked as
Succeeded. - Configured retry policies never trigger.
- The failure never reaches dead-letter handlers or alerts.
If you need to log or perform recovery, log the error and rethrow, or record partial progress before propagating.
Legitimate Exceptions¶
In batch processing jobs handling collections of independent records in a loop, catching individual item errors, logging them, and continuing the loop may be intentional. In such cases, suppress the diagnostic locally or configure .editorconfig.
Example¶
public sealed class ProcessPaymentJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
try
{
await ChargeCardAsync(ct);
}
catch (Exception ex)
{
// Swallowed! Job finishes as 'Succeeded'; retry and dead-letter are skipped.
_logger.LogError(ex, "Failed to charge card");
}
}
}
public sealed class ProcessPaymentJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
try
{
await ChargeCardAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to charge card; will retry");
throw; // Rethrow to trigger NexJob retry & dead-letter pipeline
}
}
}
NXJ018¶
Title: Avoid swallowing OperationCanceledException in job execution
Category: Reliability
Default Severity: Info
Catching OperationCanceledException or TaskCanceledException without rethrowing causes graceful host shutdowns to appear as normal completions. NexJob's dispatcher detects unhandled cancellation during shutdown and automatically requeues the job without consuming an attempt. If cancellation is swallowed, the job is marked as Succeeded even though it was aborted prematurely.
Legitimate Exceptions¶
If your job uses an internal per-call timeout via a linked CancellationTokenSource (e.g. cts.CancelAfter(5000) for a single remote call) and intentionally catches that timeout to fall back to an alternate path, catching OperationCanceledException for that specific token is legitimate. You can suppress the rule on that method or block.
Example¶
public sealed class HeavyReportJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
try
{
await GenerateReportAsync(ct);
}
catch (OperationCanceledException)
{
// Swallowed! Host shutdown aborts early but job is marked Succeeded without requeue.
_logger.LogWarning("Report cancelled");
}
}
}
public sealed class HeavyReportJob : IJob
{
public async Task ExecuteAsync(CancellationToken ct)
{
try
{
await GenerateReportAsync(ct);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Shutdown or timeout signalled; propagating for requeue");
throw; // Propagate so NexJob can requeue the job
}
}
}
NXJ019¶
Title: Invalid job attribute argument
Category: Reliability
Default Severity: Info
NexJob validates job attributes lazily at runtime when the job is first executed by a worker. Supplying invalid compile-time constants (such as negative retry attempts, non-positive concurrency limits, or invalid timeout strings) causes runtime ArgumentOutOfRangeException or FormatException. NXJ019 catches these configuration errors directly at compile time.
Validated attributes include:
[Retry(attempts)]:attemptsmust be>= 0.[Throttle(resource, maxConcurrent)]:resourcemust not be null or whitespace andmaxConcurrentmust be>= 1.[ExecutionTimeout(timeSpanString)]: must be a valid, positiveTimeSpan.
The [Retry] properties InitialDelay and MaxDelay are not checked by this rule: they are parsed with TimeSpan.Parse when a retry delay is computed, so a malformed string only fails then.
Example¶
// Invalid: negative retry attempts, zero concurrency, negative timeout
[Retry(-1)]
[Throttle("api", 0)]
[ExecutionTimeout("-00:10:00")]
public sealed class OrderJob : IJob
{
public Task ExecuteAsync(CancellationToken ct) => Task.CompletedTask;
}
[Retry(3)]
[Throttle("api", 5)]
[ExecutionTimeout("00:10:00")]
public sealed class OrderJob : IJob
{
public Task ExecuteAsync(CancellationToken ct) => Task.CompletedTask;
}