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.

JobAttribute.cs
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method,
    AllowMultiple = false, Inherited = false)]
public sealed class JobAttribute(string wireName) : Attribute

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

PropertyTypeDefaultMeaning
WireNamestring (get-only)required, from the constructorThe stable identifier for this job type on the wire and in storage. Mandatory and explicit; never derived from the CLR type or method name.
Queuestring"default"The Queue jobs of this type go to unless overridden at enqueue time.
Labelsstring[][]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.

OrderCharged.cs
[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.

SendReceipt.cs
[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.

RetryAttribute.cs
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method,
    AllowMultiple = false, Inherited = false)]
public sealed class RetryAttribute(int maxAttempts, params double[] backoffSeconds) : Attribute
PropertyTypeDefaultMeaning
MaxAttemptsint (get-only)required, from the constructorThe attempt ceiling: the most attempts before the job dead-letters. From 1 to 1000.
BackoffSecondsdouble[] (get-only)required, from the constructorThe 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.

ChargeCard.cs
[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.

IJobHandler.cs
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.

MemberSignatureMeaning
JobIdGuid JobId { get; init; }The id of the job this Attempt is running.
Attemptint Attempt { get; init; }The current Attempt number, starting at 1 for the first execution try.
BufferedTagsJobTags BufferedTags { get; }The runtime Tags buffered so far during this Attempt. Set semantics.
BufferedOutputReadOnlyMemory<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.

Tagging.cs
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.

SetOutput.cs
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.

GetDependencyOutput.cs
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>.

DependencyOutput.cs
public sealed record DependencyOutput<T>(JobState AncestorState, bool HasOutput, T? Output);
MemberMeaning
AncestorStateThe ancestor's current terminal job state.
HasOutputTrue only when the ancestor persisted a non-null output blob.
OutputThe 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.

JobOutputCodec.cs
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.

BackWaveJobs.cs
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.

Startup.cs
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.

JobModule.cs
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.

JobRegistration.cs
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.

IDTitleTrigger
BW0001Wire Name is missing[Job] with a null, empty, or whitespace Wire Name.
BW0002Duplicate Wire NameThe same Wire Name on two declarations.
BW0003No handler for [Job] typeNo IJobHandler<T> found in the compilation for a record job.
BW0004Unsupported payload member typeA payload member type the generator cannot serialize.
BW0005Invalid [Job] method shapeMethod sugar that is not public or does not return Task.
BW0006Duplicate generated job typeTwo [Job]s that resolve to the same payload type.
BW0007Workflow type is not listed in any JsonSerializerContextA workflow output type or seed with no generated serializer to wire.
BW0008Invalid [Retry] attempt ceilingA [Retry] ceiling below 1 or above 1000.
BW0009Invalid [Retry] backoff intervalsA [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 GetDependencyOutputAsync resolves against.
  • Client, Monitor & Operator API: enqueuing jobs and overriding the Queue or Tags at the call site.
  • Job States & Transitions: the JobState values 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