# Job & Handler API

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](/docs/core-concepts/jobs-and-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.

```csharp title="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.

| 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](/docs/core-concepts/queues) jobs of this type go to unless overridden at enqueue time. |
| `Labels` | `string[]` | `[]` | Default [Tag](/docs/core-concepts/tags) 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.

```csharp title="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`.

```csharp title="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](/docs/core-concepts/worker-groups)
`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.

```csharp title="RetryAttribute.cs"
[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.

```csharp title="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](/docs/core-concepts/execution-model) 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](/docs/guides/configure-retries-and-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.

```csharp title="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`](#the-job-context), 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](/docs/core-concepts/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.

```csharp title="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](/docs/core-concepts/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](/docs/core-concepts/dependencies)
descendant can later pull. Output is serialized immediately and buffered, then persisted only
if the Attempt succeeds.

```csharp title="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.

```csharp title="GetDependencyOutput.cs"
public async ValueTask<DependencyOutput<T>> GetDependencyOutputAsync<T>(
    string nameOrJobId, JsonTypeInfo<T> typeInfo, CancellationToken cancellationToken = default)
```

`nameOrJobId` is either a [Workflow](/docs/core-concepts/workflows) 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>`.

```csharp title="DependencyOutput.cs"
public sealed record DependencyOutput<T>(JobState AncestorState, bool HasOutput, T? Output);
```

| Member | Meaning |
|---|---|
| `AncestorState` | The ancestor's current terminal [job state](/docs/reference/job-states). |
| `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.

```csharp title="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.

```csharp title="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.

```csharp title="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.

```csharp title="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.

```csharp title="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.

| 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](/docs/core-concepts/jobs-and-handlers): the model behind the `[Job]`
  attribute, Wire Names, and the two authoring forms.
- [Tags](/docs/core-concepts/tags): Labels versus Keyed Tags and how default Labels combine
  with per-enqueue Tags.
- [Dependencies](/docs/core-concepts/dependencies): how Job Output flows from an ancestor to a
  descendant.
- [Workflows](/docs/core-concepts/workflows): named nodes and the ancestor set
  `GetDependencyOutputAsync` resolves against.
- [Client, Monitor & Operator API](/docs/reference/client-api): enqueuing jobs and overriding
  the Queue or Tags at the call site.
- [Job States & Transitions](/docs/reference/job-states): the `JobState` values an ancestor can
  carry.
- [Limits & Defaults](/docs/reference/limits-and-defaults): the default Queue name and other
  defaults in one place.
