Jobs & Handlers

What a job is: an opaque unit of work with a stable Wire Name, a serializable payload, and a handler, kept refactor-safe by the Job Manifest.


A job is one unit of background work. You enqueue it, BackWave runs it once on some Worker with retries, and it reaches a terminal state. The work itself lives in a handler, a method that takes a typed payload and does something. Everything else around that method, the serialization, the registration, the routing, is generated for you.

A job is opaque#

BackWave never looks inside a handler. It records that a job moved between states in the Transition Log, but it does not record or replay the steps you run inside the body. There is no signal, no wait, no conditional that reshapes the work based on a result. The body runs start to finish, then reports an outcome.

This is the line between a background job system and durable execution. BackWave is the former. If a handler charges a card and then sends an email, BackWave sees one job that Succeeded or failed, not two recorded steps it can resume between. That keeps the model small and the storage layer the only thing it has to reason about, but it also means the durability of your work is your responsibility inside the body. See Execution Guarantee for what that buys you and what it asks of you.

Declaring a job#

You mark a job with the [Job] attribute and one required argument, its Wire Name. Two optional named arguments come with it: Queue, which defaults to "default", and Labels, the default Tag Labels every job of this type starts with. The attribute targets a class, a struct, or a method.

The Wire Name is the job type's identity in storage and on the wire. It is explicit and never derived from the CLR type or method name. Rename the class, rename the method, move it to another namespace, and the identity stored with jobs already in the queue stays put. An empty Wire Name is a compile-time error, not a runtime surprise, and the name must be unique across the application.

Labels are additive only and hold bare strings, since the attribute takes compile-time constants. Tags are observational and the Core never reads them; see Tags.

Two shapes#

The shape you attribute decides how much the generator writes.

The method form is the common one. Put [Job] on a method and the generator reads its signature to write both the payload record and the handler.

SampleJobs.cs
public sealed class SampleJobs(ILogger<SampleJobs> logger, ConcurrencyTracker tracker)
{
    [Job("greet", Queue = "critical")]
    public async Task GreetAsync(string name, int delayMs, JobContext context, CancellationToken cancellationToken)
    {
        logger.LogInformation("greet: hello {Name} (job {JobId}, attempt {Attempt})", name, context.JobId, context.Attempt);
        if (delayMs > 0)
        {
            await Task.Delay(delayMs, cancellationToken);
        }
    }
}

The class form is for when you want to write the handler by hand. Attribute a payload record and pair it with an IJobHandler<T> in the same compilation. The generator emits the serialization and the registry entry; it finds your handler and wires it in.

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)
        => /* ... */;
}

What the generator emits#

The source generator ships in the box, with no extra package. For each [Job] it writes the parts you would otherwise hand-roll: a payload record (method form only), an IJobHandler<T> implementation, reflection-free JSON serialization, and a registration entry. For a method-form job, the generated handler injects your declaring class and forwards the call to your method, so your method is the whole handler.

It also emits an assembly-wide entry point, BackWaveJobs.Module, that gathers every registration in the project. You never hand-write serialization or registration, and there is nothing to keep in sync when you add a job.

The handler contract#

Every job runs through one interface, IJobHandler<TJob>, with one method: HandleAsync, which receives the job payload, a JobContext, and a CancellationToken.

Returning normally signals success. Throwing signals failure and schedules a retry until the job's attempt ceiling is reached, after which it is Dead-Lettered. The CancellationToken is signaled when the Attempt is being torn down, on shutdown or Lease loss; honor it to stop promptly. Cancellation is cooperative, never a thread kill. The retry loop, backoff, and ceiling belong to Job Lifecycle.

JobContext#

The second parameter is the per-Attempt execution context. It tells the handler which job it is running (JobId) and which Attempt this is (Attempt), and it buffers anything the handler wants to record through AddLabel, AddTag, and SetOutput.

Attempt starts at 1 for the first try and climbs with each retry. AddLabel and AddTag buffer runtime Tags with set semantics, so adding the same Tag twice collapses. SetOutput buffers one opaque Job Output blob that a descendant can later pull with GetDependencyOutputAsync. Output is buffered on the context, not returned from HandleAsync; it flushes as a delta on the Attempt's outcome write and persists only on success. JobContext is a class, not a record, precisely because it holds those mutable buffers. Job Output and reading an ancestor's output are covered in Dependencies.

One DI scope per Attempt#

BackWave opens a fresh DI scope around exactly one Attempt's handler invocation and resolves the handler from that scope. Anything the handler injects, a scoped DbContext, an HTTP client, resolves fresh for that Attempt and is disposed when the Attempt ends. The number of live scopes equals the number of in-flight Attempts, bounded by your Worker pool size.

Handlers and the declaring class default to scoped, and there is no knob to change that. A handler is resolved once per scope, so transient and scoped behave the same for the instance itself. Scoped is the right default because the handler's dependencies then get per-Attempt scoping and disposal. This matters because delivery is at-least-once, so making the work idempotent is the author's job, and the canonical idempotent move is a dedup write through a scoped DbContext. A root-resolved handler could not depend on scoped services and would leak disposables for the process lifetime.

A class that declares [Job] methods behaves like an MVC controller for jobs. It is resolved once per Attempt, holds the per-Attempt scoped dependencies, and dispatches to its job methods. Process-wide shared state does not belong on it. In the sample, SampleJobs is scoped and the ConcurrencyTracker it shares is an injected singleton, registered separately. Put expensive or shared state in an injected singleton, never on the declaring class.

At-least-once execution#

A handler body may run more than once. A crash or a lapsed Lease after the work runs but before the outcome is recorded leads to a retry, and the body runs again. Idempotency is the handler author's responsibility.

What BackWave does guarantee exactly once is Effect-Once. The recorded outcome of an Attempt and every state transition that flows from it apply once, fenced by a (workerId, attempt) check at the Storage Contract boundary. At-least-once execution and Effect-Once coexist, and the first is exactly why the second cannot extend to your side effects. Execution Guarantee draws the boundary and shows idempotency patterns.

The Job Registry and routing#

The Job Registry is the set of all registered job types keyed by Wire Name. Building it validates the registrations, so every Wire Name must be non-empty and no Wire Name or job type may repeat. A duplicate is an InvalidOperationException at startup, not a silent override.

When a Worker claims a job, the registry routes it. A known Wire Name whose payload still decodes routes to its registration and the deserialized payload. Anything else is Unroutable, either because the Wire Name has no registered handler or because the stored payload no longer deserializes.

Both Unroutable causes send the job to Quarantined. Quarantined is a loud terminal state, never a silent retry loop. Keep it distinct from Dead-Lettered, which is a job that ran and exhausted its attempt ceiling.

The Job Manifest#

Renaming is safe because of the Wire Name, and the thing that guards the Wire Name is the Job Manifest. The Manifest is a committed snapshot, one line per registration, sorted by Wire Name, verified by the shipped BackWave.Testing helper. Each line pairs a Wire Name with the job type behind it:

JobManifest.txt
order-charged => Acme.Jobs.OrderCharged
send-welcome  => Acme.Jobs.SendWelcome

The helper creates the file on first run and rewrites it when you add a job, so the new entry shows up in the pull request diff. Removing or renaming a Wire Name, or changing the payload type behind an existing one, fails the test. That makes a wire-format change a reviewed act instead of a production discovery.

This shapes how you evolve a job. Renaming the class is free, because the Wire Name is the stored identity and the Manifest test confirms it did not change. A breaking payload change gets a new Wire Name, and you keep the old handler registered until in-flight jobs under the old name have drained. The procedure lives in Evolve Job Payloads.

Enqueuing#

BackWaveClient is the enqueue surface. You hand it a generated payload and a due time.

Program.cs
var jobId = await client.EnqueueAsync(new SendReceipt(orderId), DateTimeOffset.UtcNow);

EnqueueAsync<TJob> looks up the registration by the CLR job type, generates a job id, serializes the payload through that registration, defaults the Queue to the registration's Queue (you can override it), and writes a new job carrying the Wire Name. Enqueuing "now" is just a due time of the current instant; it is the same code path as future-scheduled work. The full enqueue surface, including chaining and recurring schedules, lives in Define and Enqueue a Job.

Wiring it up#

One call registers everything the generator produced: the Job Registry, a scoped handler per [Job], and the scoped declaring class.

Program.cs
builder.Services.AddBackWave(backwave =>
{
    backwave
        .UseStore(_ => store.CreateStore(builder.Configuration))
        .UseJobs(BackWaveJobs.Module);
});

There is no hand-written registration to drift out of sync with your jobs. Add a [Job], and BackWaveJobs.Module carries it.

Where to go next#

  • Job Lifecycle: the states a job moves through, the retry loop, and what sends it to Dead-Lettered.
  • Execution Guarantee: at-least-once execution, Effect-Once, and how to make handlers idempotent.
  • Dependencies: emitting Job Output with SetOutput and pulling an ancestor's output.
  • Tags: the default Labels on [Job] and the runtime Tags a handler buffers.
  • Define and Enqueue a Job: the enqueue procedures end to end.
  • Evolve Job Payloads: the Job Manifest test and changing a payload safely.