Skip to main content

Job Configuration

This page covers how to configure job behavior using attributes and how to access job data inside a handler via JobContext.

JobContext

Every handler receives a JobContext in HandleAsync. It provides access to the job's payload, metadata, and identity:

PropertyDescription
job.IdThe unique identifier of the job
job.MsgDataThe data payload (arguments) sent when the job was scheduled
job.MetadataNon-business data (e.g., correlation IDs, tracking tags)
public async Task HandleAsync(JobContext job)
{
var name = job.MsgData.TryGetStringValue("Name") ?? "World";
var source = job.Metadata.TryGetStringValue("Source");

Console.WriteLine($"[{job.Id}] Hello {name} from {source}");
}

Message Data

MsgData is the typed key-value payload attached to a job. You write it at the call site and read it inside the handler.

Writing — build the payload before scheduling:

var msg = WriteableMessageData.New()
.SetStringValue("UserEmail", "john@example.com")
.SetIntValue("RetryCount", 1)
.SetLongValue("OrderId", 9876543210L)
.SetBoolValue("SendReceipt", true)
.SetDecimalValue("Amount", 99.99m)
.SetDateTimeValue("ScheduledAt", DateTime.UtcNow);

await scheduler.OnceNowAsync<NotificationHandler>(msg);

Reading — access values inside HandleAsync:

MethodReturnsBehavior
GetStringValue("key")stringThrows if key is missing
TryGetStringValue("key")string?Returns null if missing
GetIntValue("key")intThrows if key is missing
TryGetIntValue("key")int?Returns null if missing
GetLongValue("key")longThrows if key is missing
GetBoolValue("key")boolThrows if key is missing
GetDecimalValue("key")decimalThrows if key is missing
GetDateTimeValue("key")DateTimeThrows if key is missing
public async Task HandleAsync(JobContext job)
{
var email = job.MsgData.GetStringValue("UserEmail");
var amount = job.MsgData.TryGetDecimalValue("Amount") ?? 0m;
}

JSON Objects

Use SetJson<T> / GetJson<T> to store and retrieve complex objects. The type must have a parameterless constructor.

public class OrderPayload
{
public int OrderId { get; set; }
public string CustomerEmail { get; set; } = "";
public decimal Total { get; set; }
}

// Writing
var msg = WriteableMessageData.New()
.SetJson("Order", new OrderPayload { OrderId = 42, CustomerEmail = "john@example.com", Total = 99.99m });

// Reading
public async Task HandleAsync(JobContext job)
{
var order = job.MsgData.GetJson<OrderPayload>("Order");
var optional = job.MsgData.TryGetJson<OrderPayload>("Order"); // returns null if missing
}

Dependency Injection

Each job execution runs in its own DI scope. JobMaster resolves handler instances and their dependencies from that scope, so scoped services work correctly — each job gets a fresh set of resolved services. Inject your services directly into the constructor:

public class NotificationHandler : IJobMasterHandler
{
private readonly IEmailService _emailService;

public NotificationHandler(IEmailService emailService)
{
_emailService = emailService;
}

public async Task HandleAsync(JobContext job)
{
var email = job.MsgData.GetStringValue("UserEmail");
await _emailService.SendAsync(email, "Your report is ready!");
}
}

Attributes

Use attributes on the handler class to control how the cluster treats your jobs.

JobDefinitionId

Defines the stable identity of the job. Defaults to the class full name.

[JobMasterDefinitionId("HelloJob")]
public sealed class HelloJobHandler : IJobMasterHandler
warning

Always define a static DefinitionId. If you rename the class or move it to a different namespace without one, the cluster will fail to map existing persisted jobs to the new code.

Timeout

Maximum time the job is allowed to run before being forcefully terminated.

[JobMasterTimeout(10)] // seconds
public sealed class HelloJobHandler : IJobMasterHandler

Max Retries

How many times the cluster should retry the job on failure before marking it as Failed.

[JobMasterMaxNumberOfRetries(3)]
public sealed class HelloJobHandler : IJobMasterHandler

Priority

Influences execution order and the share of worker resources the job receives.

[JobMasterPriority(JobMasterPriority.High)]
public sealed class HelloJobHandler : IJobMasterHandler
warning

If the resolved priority is disabled on the cluster (via DisablePriority(...)), scheduling throws InvalidOperationException at the call site. Handlers decorated with a disabled priority also throw at cluster startup. See Cluster Configuration — DisablePriority.

Worker Lane

A lane is a named execution channel that isolates a group of jobs to a dedicated set of workers. Jobs without a lane assigned compete for the same workers.

By assigning a lane, you ensure that slow or resource-intensive jobs have their own dedicated workers and never block unrelated workloads.

[JobMasterWorkerLane("PaymentsProcessing")]
public sealed class HelloJobHandler : IJobMasterHandler

Metadata

Attaches extra information to the job for categorization, auditing, or custom logic.

[JobMasterMetadata("Category", "Payroll")]
public sealed class HelloJobHandler : IJobMasterHandler

Publisher/Consumer Separation (Advanced)

Every method shown so far (OnceNowAsync<HelloJobHandler>(...), etc.) requires the caller to reference the handler type — and therefore the assembly it lives in. That's fine when the publisher and the worker that processes the job share a codebase, but it doesn't work when they're meant to be fully separate deployables (e.g. a web API that schedules work a completely different service executes).

IJobMasterScheduler.Advanced schedules against a definition — an id plus optional priority/timeout/retries/worker-lane/metadata — instead of a concrete handler type, so the publisher never needs the consumer's assembly at all. There are two ways to build that definition; pick whichever fits how your codebase is organized.

Config lives with the definition, not the handler

This is the opposite of the classic pattern, where [JobMasterPriority]/[JobMasterTimeout]/etc. live on the handler class. Here, priority/timeout/retries/worker-lane are attached to the definition — the JobDefinitionConfig (or the attribute wrapping it) — and resolved at the moment you build or reference that definition, before scheduling. The consumer's handler doesn't need to declare any of them at all; it only needs to be resolvable by the definition's id.

Option 1: build a JobDefinitionConfig directly

No shared contracts project needed here — the publisher builds the definition inline, at the call site:

var config = new JobDefinitionConfig("orders.process", priority: JobMasterPriority.High);
await scheduler.Advanced.OnceNowAsync(config, msg);

The consumer side doesn't need JobDefinitionConfigAttribute at all in this style — just a handler whose definition id matches the string you scheduled against, via the classic [JobMasterDefinitionId] attribute:

[JobMasterDefinitionId("orders.process")]
public sealed class ProcessOrderHandler : IJobMasterHandler
{
public async Task HandleAsync(JobContext job) { ... }
}

Option 2: shared attribute in a "contracts" project

Define a JobDefinitionConfigAttribute subclass in a small shared project both sides reference:

// Shared contracts project
public sealed class ProcessOrderDefinition : JobDefinitionConfigAttribute, IStaticJobDefinitionConfig
{
public static JobDefinitionConfig Config { get; } = new JobDefinitionConfig(
"orders.process",
priority: JobMasterPriority.High,
timeout: TimeSpan.FromSeconds(30));
}

The publisher references only the shared contract:

await scheduler.Advanced.OnceNowAsync<ProcessOrderDefinition>(msg);

The consumer applies the same attribute to its handler:

[ProcessOrderDefinition]
public sealed class ProcessOrderHandler : IJobMasterHandler
{
public async Task HandleAsync(JobContext job) { ... }
}

Both sides agree on the job's identity and default configuration through the shared attribute type — the publisher never sees ProcessOrderHandler, and the consumer's assembly never needs to be referenced by the publisher's project.

Config is deliberately static, not an instance property: it's a fixed identity shared by every call site using ProcessOrderDefinition, so there's no way for two different call sites to silently disagree about what that definition means.

Which option should I use?

  • Option 1 — when a shared project between publisher and consumer isn't worth the configuration overhead — e.g. the id is a loose, documented contract rather than a compile-time-shared type. If typos are a concern but a full attribute type still feels like too much, share just the id itself as a plain constant in a small shared file (e.g. public static class JobDefinitionIds { public const string OrderProcess = "orders.process"; }) — both sides reference the constant instead of retyping the literal string, without needing to agree on priority/timeout/retries at all.
  • Option 2 — when you want the compiler to catch a typo'd id, or a definition that's drifted out of sync between the two sides (priority/timeout/retries changed on one side but not the other).

Every scheduling method has an Advanced counterpart

OnceNow/OnceAt/OnceAfter/Recurring (and their *Async variants) are all available on Advanced, both as X<TDefinition>(...) (generic over the attribute type, with the same per-call override parameters as the classic methods) and X(JobDefinitionConfig config, ...) (no override parameters — the config object already carries whatever values you built it with).

Injecting just the Advanced surface

If a service only ever schedules through Advanced, it can inject IJobMasterSchedulerAdvanced directly from DI instead of going through IJobMasterScheduler.Advanced.

Configuration Hierarchy

Job configuration is resolved in order of precedence — the first value found wins:

Setting1st (highest)2nd3rd (fallback)
PriorityEnqueue call parameter[JobMasterPriority] attributeMedium
TimeoutEnqueue call parameter[JobMasterTimeout] attributeCluster DefaultJobTimeout
Max RetriesEnqueue call parameter[JobMasterMaxNumberOfRetries] attributeCluster DefaultMaxRetryCount
Worker LaneEnqueue call parameter[JobMasterWorkerLane] attributeNo lane

Cluster-level defaults (DefaultJobTimeout, DefaultMaxRetryCount) are configured in the Cluster Configuration.

This means you can define sensible defaults on the handler class and selectively override them at the call site when needed:

[JobMasterPriority(JobMasterPriority.Low)]
[JobMasterTimeout(30)]
public sealed class ReportHandler : IJobMasterHandler { ... }

// Uses handler defaults (Low priority, 30s timeout)
await scheduler.OnceNowAsync<ReportHandler>(msg);

// Overrides priority only — timeout still comes from the attribute
await scheduler.OnceNowAsync<ReportHandler>(
msg,
priority: JobMasterPriority.Critical
);