Job & Handler API
The job-authoring surface: the [Job] attribute, the IJobHandler interface, the JobContext passed to a handler, and the generated registry module.
Every BackWave job is authored from four pieces: the [Job] attribute that names a job type
and sets its defaults, the IJobHandler<TJob> interface that runs it, the JobContext handed
to each execution, and the registry module a source generator emits from the attributes it
finds. You annotate a payload type (or a handler method) with [Job], write the handler, and
the generator produces the registration wiring so the runtime can route, deserialize, and
execute the job. This page documents that public surface. The Jobs & Handlers
concept page covers the model behind it.
The [Job] attribute#
[Job] marks a type or method as a job and carries the metadata the runtime needs to route
it. It takes one required positional argument, the Wire Name, and exposes two optional named
properties for the Queue and default Labels.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method,
AllowMultiple = false, Inherited = false)]
public sealed class JobAttribute(string wireName) : AttributeThe attribute may sit on a class, a struct, or a method. It is not repeatable and not
inherited, so each job type declares its own [Job] exactly once.
| Property | Type | Default | Meaning |
|---|---|---|---|
WireName | string (get-only) | required, from the constructor | The stable identifier for this job type on the wire and in storage. Mandatory and explicit; never derived from the CLR type or method name. |
Queue | string | "default" | The Queue jobs of this type go to unless overridden at enqueue time. |
Labels | string[] | [] | Default Tag Labels every job of this type starts with. |
The Wire Name is the one identifier you must choose and keep stable, because it is what the
runtime persists and matches on; renaming the CLR type does not change it. Labels are
additive-only: they are always unioned into a job's Tags at enqueue and never subtracted. Only
Labels (bare strings) can be declared here, because the attribute takes compile-time constants
and a key/value Keyed Tag cannot be encoded as a single constant. A caller can still attach a
Keyed Tag at enqueue time.
Two ways to author a job#
The class form attributes the payload record. The IJobHandler<TJob> for that payload, when it
lives in the same compilation, is paired to it automatically.
[Job("order-charged")]
public sealed record OrderCharged(Guid OrderId);
public sealed class OrderChargedHandler : IJobHandler<OrderCharged>
{
public Task HandleAsync(OrderCharged job, JobContext context, CancellationToken cancellationToken)
=> /* ... */;
}The method form (method sugar) attributes a handler method directly. The generator writes the
payload record and the handler for you from the method's signature. A parameter typed
JobContext or CancellationToken is passed through; every other parameter becomes a payload
member. The method must be public and return Task.
[Job("send-receipt")]
public Task SendReceipt(Guid orderId, CancellationToken cancellationToken)
=> /* ... */;The [Retry] attribute#
[Retry] gives one job type its own retry shape, in place of the Worker Group's
RetryPolicy. It sits beside [Job] on the same class, struct, or method. It is optional, and a
type without it inherits the group policy as before.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method,
AllowMultiple = false, Inherited = false)]
public sealed class RetryAttribute(int maxAttempts, params double[] backoffSeconds) : Attribute| Property | Type | Default | Meaning |
|---|---|---|---|
MaxAttempts | int (get-only) | required, from the constructor | The attempt ceiling: the most attempts before the job dead-letters. From 1 to 1000. |
BackoffSeconds | double[] (get-only) | required, from the constructor | The delay before each retry, in seconds, one value per retryable attempt. From one to twenty values, none negative. |
Both arguments are compile-time constants. That is what lets the source generator read and validate them at build time. This charge runs at most three times. It waits one second before attempt 2 and five seconds before attempt 3.
[Job("charge-card")]
[Retry(3, 1, 5)]
public sealed record ChargeCard(Guid OrderId);When the backoff list is shorter than the ceiling, the last value repeats for the rest of the
attempts. [Retry(10, 1, 5)] waits one second before attempt 2 and five seconds before every
attempt after that. MaxAttempts counts total attempts, not extra tries. [Retry(1, 0)] never
retries, and the first failure dead-letters. At least one interval is always necessary, even at a
ceiling of one where nothing consumes it. The list holds at most twenty values, so the attribute
expresses a fixed schedule, not a computed curve. A curve stays a Backoff delegate on the
group's RetryPolicy.
The override applies on the loud-failure path, where a handler threw. The store disposes an
attempt that dies with its Lease on a crash or a stall,
and the Worker Group policy governs that attempt. Three build errors guard the attribute: BW0008 for a ceiling outside the range, BW0009 for an invalid backoff list, and
BW0010 for a [Retry] on a type that carries no [Job]. Retries & Error
Handling covers how the two levels sit
together.
Writing a handler#
A handler implements IJobHandler<TJob> for the payload it processes. The interface has a
single method.
public interface IJobHandler<in TJob>
{
Task HandleAsync(TJob job, JobContext context, CancellationToken cancellationToken);
}TJob is the payload type this handler processes. HandleAsync receives the deserialized
payload, the per-Attempt JobContext, and a CancellationToken.
The return value is the outcome signal. Returning normally signals success. Throwing signals
failure, which schedules a retry until the job's attempt ceiling is reached. The
CancellationToken is signaled when the Attempt is being torn down, for example on shutdown or
lease loss; honor it to stop promptly.
BackWave runs jobs at least once, so a handler body may run more than once for the same job. Idempotency is the author's responsibility. The Execution Guarantee page covers what that guarantee does and does not promise.
The job context#
JobContext is the per-Attempt context passed to HandleAsync. It identifies the running job
and Attempt, and buffers the Tag and output writes a handler makes. Those buffered writes flush
as a delta on the Attempt's outcome write and commit atomically with the outcome, so a Tag or
output written during an Attempt lands exactly when that Attempt's outcome does.
| Member | Signature | Meaning |
|---|---|---|
JobId | Guid JobId { get; init; } | The id of the job this Attempt is running. |
Attempt | int Attempt { get; init; } | The current Attempt number, starting at 1 for the first execution try. |
BufferedTags | JobTags BufferedTags { get; } | The runtime Tags buffered so far during this Attempt. Set semantics. |
BufferedOutput | ReadOnlyMemory<byte>? BufferedOutput { get; } | The buffered Job Output blob, already serialized. Null until SetOutput is called; persisted only on a successful outcome. |
Attempt counts every execution try. A lease expiry counts as an Attempt just as a thrown
exception does, so the number reflects real tries rather than only failures you observed.
Adding Tags#
A handler can annotate the running job with a Label or a Keyed Tag. Both writes are idempotent under set semantics: re-adding a Tag that is already present is a no-op.
public Task HandleAsync(OrderCharged job, JobContext context, CancellationToken cancellationToken)
{
context.AddLabel("reconciled");
context.AddTag("region", "us-east-1");
return Task.CompletedTask;
}AddLabel(string value) adds a Label (a bare string). A colon inside the value is ordinary
data, never a separator. AddTag(string key, string value) adds a Keyed Tag. Both throw
ArgumentException when given a null or empty argument. See Tags
for the Label-versus-Keyed-Tag distinction.
Emitting Job Output#
A handler may emit one Job Output: a single opaque value that a transitive Dependency descendant can later pull. Output is serialized immediately and buffered, then persisted only if the Attempt succeeds.
public void SetOutput<T>(T value, JsonTypeInfo<T> typeInfo)The value is serialized through the same JSON serializer as the payload, using the
source-generated JsonTypeInfo<T> you pass, which keeps the path reflection-free. Within an
Attempt the last write wins. The output commits atomically with the outcome and persists only
on success. The store rejects output over its size limit at write time rather than truncating
it.
Reading an ancestor's Job Output#
A handler can pull the Job Output of one of its transitive Dependency ancestors.
public async ValueTask<DependencyOutput<T>> GetDependencyOutputAsync<T>(
string nameOrJobId, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)nameOrJobId is either a Workflow node name, resolved against
this job's ancestor set, or a Guid-shaped string identifying a raw Dependency. A name that
resolves to a non-ancestor, such as a sibling, is unresolvable by design. The read is lazy: it
happens only when you call the method, with no speculative IO. The blob deserializes with the
same JsonTypeInfo<T> shape the producer wrote.
Absence is normal and does not throw. A failed, cancelled, or discarded ancestor, or a
succeeded one that emitted nothing, comes back with HasOutput == false. When the handle
resolves to no ancestor at all, the result is empty and its AncestorState is the
AwaitingParent sentinel. The method throws InvalidOperationException only when the context
was not built for handler execution.
The result is a DependencyOutput<T>.
public sealed record DependencyOutput<T>(JobState AncestorState, bool HasOutput, T? Output);| Member | Meaning |
|---|---|
AncestorState | The ancestor's current terminal job state. |
HasOutput | True only when the ancestor persisted a non-null output blob. |
Output | The deserialized output, or default when HasOutput is false. |
Check HasOutput, or branch on AncestorState, before using Output. Reading a dependency's
output only surfaces settled facts; it never creates, skips, or reorders any node.
The output codec#
JobOutputCodec is the single point that converts a handler's typed output to and from the
opaque blob. It uses the same JSON serializer as the payload, so the producer's shape equals
the reader's shape.
public static class JobOutputCodec
{
public static ReadOnlyMemory<byte> Encode<T>(T value, JsonTypeInfo<T> typeInfo);
public static T Decode<T>(ReadOnlyMemory<byte> output, JsonTypeInfo<T> typeInfo);
}Encode and Decode are pure: no IO, no clock, no size bound, since the store enforces size
at write time. Decode throws InvalidOperationException when the blob deserializes to null.
The generated registry module#
The source generator scans your compilation for [Job] attributes and emits a static entry
point, BackWaveJobs, in the BackWave.Generated namespace. It exposes one registration per
job, ordered by Wire Name, and a ready-made module.
namespace BackWave.Generated;
public static class BackWaveJobs
{
public static IReadOnlyList<JobRegistration> CreateRegistrations();
public static JobRegistry CreateRegistry();
public static JobModule Module { get; }
}The canonical usage is to pass BackWaveJobs.Module to UseJobs when you configure BackWave.
That one call registers the registry, one handler registration per [Job], and one
registration per method-sugar declaring class.
builder.Services.AddBackWave(bw =>
{
bw.UseJobs(BackWaveJobs.Module);
});Handlers and method-sugar declaring classes are registered scoped, so each is resolved once per Attempt. A registry is required: registration fails without one. The generator produces no reflection or expression trees, so the whole path is NativeAOT- and trim-safe.
The JobModule the generator emits is a plain, DI-free bundle you can also build by hand.
public sealed class JobModule
{
public required IReadOnlyList<JobRegistration> Registrations { get; init; }
public required IReadOnlyList<JobHandlerMapping> Handlers { get; init; }
public required IReadOnlyList<JobContainingType> ContainingTypes { get; init; }
public JobRegistry CreateRegistry();
}All three lists are ordered by Wire Name. Registrations carries the routing metadata,
Handlers maps each IJobHandler<T> service type to its implementation, and ContainingTypes
lists the classes that declare non-static method-sugar jobs so they can be registered for you.
Generated serialization#
For each job the generator emits straight-line serialization. Decoding is tolerant: unknown
JSON properties are skipped and missing ones default. Enums are the exception; they decode
strictly, and an unparseable enum token throws JsonException rather than defaulting silently.
The generator classifies these payload member types: string, bool, the numeric types
(byte, sbyte, short, ushort, int, uint, long, ulong, float, double,
decimal), Guid, DateTime, DateTimeOffset, and enums. Nullable value types are
supported. A nullable string is not supported. A member the generator cannot classify raises
diagnostic BW0004 and must be hand-registered.
Hand-registering a job#
When the generator cannot serialize a payload, register the job by hand with
JobRegistration.Create. It is the escape hatch for that case.
public static JobRegistration Create<TJob, THandler>(
string wireName,
JsonTypeInfo<TJob> typeInfo,
string queue = "default",
string[]? labels = null,
JsonTypeInfo? outputTypeInfo = null,
RetryDisposition? retry = null)
where THandler : IJobHandler<TJob>;The queue default is the literal "default", matching the attribute. labels become the
job type's default Labels, so they are always Labels. retry is the hand-built form of
[Retry]: build it with RetryDisposition.FromIntervals(maxAttempts, intervals), which
validates the same ceiling and the same interval list the attribute does and throws on a bad
one. Leave it null to inherit the Worker Group policy. Hand-built registrations are passed to a
JobRegistry, whose constructor validates the set and throws InvalidOperationException on an
empty or whitespace Wire Name, a duplicate Wire Name, or a duplicate job type. A registry's
Registrations are ordered by Wire Name.
Generator diagnostics#
The generator reports these compile-time errors. All are in the BackWave category, severity
Error, enabled by default.
| ID | Title | Trigger |
|---|---|---|
BW0001 | Wire Name is missing | [Job] with a null, empty, or whitespace Wire Name. |
BW0002 | Duplicate Wire Name | The same Wire Name on two declarations. |
BW0003 | No handler for [Job] type | No IJobHandler<T> found in the compilation for a record job. |
BW0004 | Unsupported payload member type | A payload member type the generator cannot serialize. |
BW0005 | Invalid [Job] method shape | Method sugar that is not public or does not return Task. |
BW0006 | Duplicate generated job type | Two [Job]s that resolve to the same payload type. |
BW0007 | Workflow type is not listed in any JsonSerializerContext | A workflow output type or seed with no generated serializer to wire. |
BW0008 | Invalid [Retry] attempt ceiling | A [Retry] ceiling below 1 or above 1000. |
BW0009 | Invalid [Retry] backoff intervals | A [Retry] backoff list that is empty, holds more than twenty intervals, or holds a negative one. |
BW0010 | [Retry] with no [Job] | A [Retry] on a type or method that carries no [Job], so the runtime ignores the override. |
Where to go next#
- Jobs & Handlers: the model behind the
[Job]attribute, Wire Names, and the two authoring forms. - Tags: Labels versus Keyed Tags and how default Labels combine with per-enqueue Tags.
- Dependencies: how Job Output flows from an ancestor to a descendant.
- Workflows: named nodes and the ancestor set
GetDependencyOutputAsyncresolves against. - Client, Monitor & Operator API: enqueuing jobs and overriding the Queue or Tags at the call site.
- Job States & Transitions: the
JobStatevalues an ancestor can carry. - Limits & Defaults: the default Queue name and other defaults in one place.
Found a problem on this page? Report an issue