# Configuration Options

Everything BackWave runs on is registered in one place: a single `AddBackWave` call at
startup. That call takes a fluent builder on which you point BackWave at a job store and a job
registry, add one or more Worker Groups, choose a history policy, and optionally attach a block
of Transition Observers. Each Worker Group carries its own options record covering pool size,
polling and lease cadences, retry, and retention. This page is the option-by-option reference
for that surface: the builder, the Worker Group options, the retry and retention policies, the
observer block, the job history policy, and the two environment switches. Defaults are stated
for every option, so an app that accepts them can register with almost nothing set.

## Registering BackWave

`AddBackWave` is an extension on `IServiceCollection`. You call it once, pass a configuration
action, and it returns the same `IServiceCollection` for chaining. Inside the action you
receive a `BackWaveBuilder` whose methods each return the builder so calls can be chained.

```csharp title="Program.cs"
services.AddBackWave(backwave =>
{
    backwave
        .UseStore(sp => new PostgresJobStore(connectionString))
        .UseJobs(BackWaveJobs.Module)
        .AddWorkerGroup(new WorkerGroupOptions
        {
            Name = "default",
            Policy = new DispatchPolicy.Strict("billing", "emails"),
        });
});
```

Two things are required: a store and a registry. If either is missing, the container fails to
build with an `InvalidOperationException`. Missing store reports `AddBackWave requires
UseStore(...): BackWave has no default storage.`; missing registry reports `AddBackWave
requires UseRegistry(...): pass BackWaveJobs.CreateRegistry() (generated) or a hand-built
JobRegistry.`. Everything else has a default.

The builder methods:

| Method | Signature | Purpose |
|---|---|---|
| `UseStore` | `UseStore(IJobStore store)` | Point BackWave at a job store. Required. |
| `UseStore` (factory) | `UseStore(Func<IServiceProvider, IJobStore> factory)` | Same, resolved from DI. The factory runs once when the store is first resolved. |
| `UseRegistry` | `UseRegistry(JobRegistry registry)` | Supply a hand-built registry. Required unless you call `UseJobs`. |
| `UseRegistry` (factory) | `UseRegistry(Func<IServiceProvider, JobRegistry> factory)` | Same, resolved from DI. |
| `UseJobs` | `UseJobs(JobModule module)` | The usual path. Registers the module's generated registry and its handler and job-declaring types in one call. Satisfies the registry requirement. |
| `UseHistoryPolicy` | `UseHistoryPolicy(JobHistoryPolicy policy)` | Set the history policy used to validate observer registration at startup. Defaults to `TransitionsAndFailureDetail`. See [Job history policy](#job-history-policy). |
| `AddWorkerGroup` | `AddWorkerGroup(WorkerGroupOptions options)` | Add one Worker Group. Call once per group. |
| `AddObservers` | `AddObservers(Action<ObserverBuilder> configure)` | Attach the Transition Observer block. See [The observer block](#the-observer-block). |

`UseStore` and `UseRegistry` each have a direct overload and a factory overload; the direct
overload delegates to the factory form. `UseJobs` is the normal way to satisfy the registry
requirement: it registers the source-generated registry along with the module's handlers and
job-declaring classes, so you rarely call `UseRegistry` by hand.

Handler classes are registered with a scoped lifetime and resolved once per Attempt.
Job-declaring classes are registered only if the host has not already registered them, so your
own registration always wins.

### Adding Worker Groups

Each `AddWorkerGroup` call adds one group, identified by its `Name`. A group runs its `Pumps`
count of independent claim loops in the process, each with its own pool and its own worker
identity, so their claims never overlap. Two rules are enforced when the group is added, both
raising an `InvalidOperationException`:

- A name may be added only once. A repeat reports `Worker Group '<name>' is configured twice.`
- `Pumps` must be at least one. A group with `Pumps` below one reports `Worker Group '<name>'
  must run at least one Pump (Pumps = <n>).`

## Worker Group options

`WorkerGroupOptions` is the record you hand to `AddWorkerGroup`. Only `Name` and `Policy` are
required; every other option has a default that suits a typical single-node deployment. The
[Worker Groups & Dispatch](/docs/core-concepts/worker-groups) page covers the runtime behavior
these options tune.

| Option | Type | Default | Meaning |
|---|---|---|---|
| `Name` | `string` | required | Unique within one `AddBackWave` call. Appears in health, metrics, and logs. |
| `Policy` | `DispatchPolicy` | required | Which Queues the group serves and how it shares effort across them. See [Dispatch policies](#dispatch-policies). |
| `PoolSize` | `int` | `20` | Maximum jobs run at once per node, per pump. Polling pauses while the pool is full and resumes as jobs finish. A flat constant, not scaled to CPU count. |
| `Pumps` | `int` | `1` | Independent claim loops in one process. Each has its own `PoolSize` pool, its own claim stream, and a distinct worker identity. Must be at least one. Budget roughly two to three database connections per pump whether idle or busy, so `Pumps = 4` costs about eight to twelve connections at steady state. Unlike pool slots, idle pumps still hold connections. |
| `MaxClaimBatch` | `int` | `32` | Maximum jobs claimed in a single poll. In practice capped at `PoolSize`, since a group never claims more than it can run. |
| `MaxOutcomeBatch` | `int?` | `null` | Maximum completed outcomes buffered before one batched report write, which keeps the writer single-threaded. A poll or heartbeat tick, or going idle, flushes a partial buffer, so a lone result is never delayed. When unset, defaults to `MaxClaimBatch`. |
| `PollInterval` | `TimeSpan` | `1 second` | How often the group polls for new work. A wake-up hint only brings a poll forward; bounded polling is the correctness mechanism. |
| `MaxPollInterval` | `TimeSpan` | `TimeSpan.Zero` | Longest wait between polls while the group is idle. Set it above `PollInterval` to enable idle backoff. Zero, or any value at or below `PollInterval`, keeps the fixed rate. See [Idle poll backoff](#idle-poll-backoff). |
| `MaintenanceInterval` | `TimeSpan` | `5 seconds` | Cadence of background maintenance: expiring lapsed leases, minting recurring schedules, and purging retained history. Separate from claim polling and should be slower than `PollInterval`. A missed sweep only delays maintenance; it never affects correctness. |
| `LeaseDuration` | `TimeSpan` | `60 seconds` | How long a claimed job's lease is held before it lapses. After this window, on a crash or stall, the job becomes re-claimable by another node. Renewed by heartbeat while the job runs. |
| `HeartbeatInterval` | `TimeSpan?` | `null` | How often in-flight leases are renewed. When unset, defaults to one third of `LeaseDuration`, which survives a couple of missed heartbeats. |
| `RetryPolicy` | `RetryPolicy` | `RetryPolicy.Default` | Backoff schedule and attempt ceiling before a job dead-letters. A Job type that carries `[Retry]` overrides it. See [Retry policy](#retry-policy). |
| `Retention` | `RetentionPolicy?` | `RetentionPolicy.Default` | Keep-then-purge policy for terminal jobs. Set to `null` to disable retention sweeping entirely. See [Retention policy](#retention-policy). |

The two nullable options resolve their default at use time rather than storing a literal:
`MaxOutcomeBatch` becomes `MaxClaimBatch`, and `HeartbeatInterval` becomes `LeaseDuration`
divided by three. `Retention` is different: `null` is not "use the default" but an explicit
instruction to stop sweeping terminal jobs at all.

## Dispatch policies

A `DispatchPolicy` declares which Queues a group serves and how it chooses among them. The
hierarchy is closed: the only two policies are `Strict` and `Weighted`, and you cannot define
your own. Both are work-conserving, so a Worker never sits idle while any served Queue has due
work.

`Strict` serves Queues in a fixed priority order, highest first. It takes either a params list
or a read-only list of Queue names.

```csharp title="StrictPolicy.cs"
var policy = new DispatchPolicy.Strict("emails", "reports");
```

`Weighted` shares claim opportunity across Queues in proportion to integer weights, honored
exactly and deterministically. It takes a list of Queue-and-weight pairs.

```csharp title="WeightedPolicy.cs"
var policy = new DispatchPolicy.Weighted([("emails", 6), ("reports", 3), ("audit", 1)]);
```

Every weight must be at least one and at least one Queue must be present; a weighted policy
built otherwise is rejected. The [Dispatch Policies](/docs/reference/dispatch-policies)
reference covers the ordering, weighting, and starvation semantics in full.

## Idle poll backoff

`MaxPollInterval` is the ceiling on how long an idle group waits between polls. It is off by
default: `TimeSpan.Zero`, or any value at or below `PollInterval`, keeps the group at the fixed
`PollInterval` and issues exactly the queries it issued before.

Above `PollInterval`, an idle poll doubles the next delay toward the ceiling. When the store
reports the instant at which the next scheduled job comes due, the group sleeps until then
instead. That sleep stays between `PollInterval` and `MaxPollInterval`. The delay returns to
`PollInterval` the moment a poll claims work. It also returns when the store reports due-now
work that a concurrency limit, a batch cap, or a full pool held back.

A Wake-Up Hint still brings the next poll forward, but it does not reset the delay to the floor.
Polling remains the sole correctness mechanism, and the ceiling bounds the worst-case pickup
latency for new work. On PostgreSQL and SQLite an enqueue wakes an idle group in milliseconds,
so the ceiling governs only the rare lost hint. On SQL Server, which polls only, the ceiling is
the direct latency bound.

```csharp title="IdleBackoff.cs"
// Poll every second under load, and stretch to 30 seconds while idle.
var options = new WorkerGroupOptions
{
    Name = "default",
    Policy = new DispatchPolicy.Strict("default"),
    PollInterval = TimeSpan.FromSeconds(1),
    MaxPollInterval = TimeSpan.FromSeconds(30),
};
```

## Retry policy

`RetryPolicy` governs what happens when an Attempt fails: how long to wait before the next
Attempt and how many Attempts to allow before the job dead-letters instead of retrying. The
first Attempt is numbered 1, and a lapsed lease counts as an Attempt.

The group policy is the baseline for every Job the group runs. A Job type that carries the
[`[Retry]` attribute](/docs/reference/job-attributes#the-retry-attribute) uses its own ceiling
and its own backoff list instead, on the path where the handler throws. After a lapsed lease,
the store schedules the next Attempt without the Job type in hand. The group policy governs
that Attempt.

| Member | Type | Default | Meaning |
|---|---|---|---|
| `MaxAttempts` | `int` | `10` | Maximum Attempts before the job dead-letters instead of retrying. |
| `Backoff` | `Func<int, TimeSpan>` | `RetryPolicy.DefaultBackoff` | Delay before the next Attempt, given the number of the Attempt that just failed. |
| `RetryPolicy.Default` | `RetryPolicy` (static) | `new()` | Up to ten Attempts with the default exponential backoff. |

`NextAttemptAt(int failedAttempt, DateTimeOffset now)` returns when the next Attempt should
run, or `null` once retries are exhausted. It returns `null` when `failedAttempt` reaches
`MaxAttempts`, which is the signal that the job dead-letters; otherwise it returns
`now + Backoff(failedAttempt)`.

`DefaultBackoff(int attempt)` is the built-in schedule: two raised to the Attempt number, in
seconds, capped at 300 seconds (five minutes). So the delays grow 2, 4, 8, 16 seconds and so
on until they flatten at the five-minute ceiling.

```csharp title="CustomRetry.cs"
var retry = new RetryPolicy
{
    MaxAttempts = 5,
    Backoff = attempt => TimeSpan.FromSeconds(30),
};
```

## Retention policy

`RetentionPolicy` decides how long terminal jobs stay queryable before a maintenance sweep
purges them. The retention clock starts the instant a job reaches its terminal state, not when
it was enqueued. Two knobs cover the four terminal states, paired by kind.

| Member | Type | Default | Meaning |
|---|---|---|---|
| `KeepSucceeded` | `TimeSpan` | `24 hours` | How long Succeeded and Cancelled jobs stay queryable before sweep. |
| `KeepDeadLettered` | `TimeSpan` | `14 days` | How long Dead-Lettered and Quarantined jobs stay queryable before sweep. |
| `RetentionPolicy.Default` | `RetentionPolicy` (static) | `new()` | 24 hours for succeeded and cancelled, 14 days for dead-lettered and quarantined. |

Succeeded and Cancelled jobs share `KeepSucceeded`; Dead-Lettered and Quarantined jobs share
`KeepDeadLettered`. To turn retention sweeping off completely, set the group's `Retention` to
`null` rather than passing a policy. The
[Retention & Purge](/docs/dashboard-operations/retention-and-purge) page covers the sweep in
operation.

## The observer block

A Transition Observer reacts to job state changes. Observers are registered together in one
block passed to `AddObservers`, which takes an action on an `ObserverBuilder`. The block is
one cohesive unit: it configures the single dispatch pump that delivers to every observer and
holds the observer list. Calling `AddObservers` more than once replaces the previous block
rather than merging, so configure all observers in one call.

When no observers are registered, nothing is registered, no pump polls, and the dashboard's
observer surface is empty. The block costs nothing when unused.

```csharp title="Observers.cs"
services.AddBackWave(backwave =>
{
    backwave
        .UseStore(sp => new PostgresJobStore(connectionString))
        .UseJobs(BackWaveJobs.Module)
        .AddObservers(observers =>
        {
            observers.ConfigurePump(pump => pump.PollInterval = TimeSpan.FromSeconds(2));
            observers.Add<PaymentAuditObserver>(
                "payment-audit",
                ObserverSubscription.AllTransitions with { WireName = "PaymentJob" });
        });
});
```

### Registering an observer

`Add<TObserver>(string id, ObserverSubscription subscription)` registers one observer, where
`TObserver` implements `ITransitionObserver`. The observer is resolved fresh from a DI scope
per delivery, so it has a scoped lifetime and may take scoped dependencies.

The `id` is a stable identifier that keys the observer's durable delivery cursor. It must be
unique within the block and stable across restarts; changing it starts a fresh cursor.
Registering the same `id` twice fails at container build with an `InvalidOperationException`
reporting `Transition Observer '<id>' is configured twice.`

Registering any observer while the history policy is `Off` also fails at container build, since
with history off no transition rows are recorded and there is nothing to observe. The message
tells you to raise the policy to `Transitions` or `TransitionsAndFailureDetail`.

### Filtering with ObserverSubscription

`ObserverSubscription` decides which transitions an observer receives. It carries a list of
`JobState` values and two optional filters, `WireName` and `Queue`. A transition matches when
its state is in the list and both filters either are unset or match by ordinal string
comparison. Start from `AllTransitions`, which subscribes to every state, and narrow it with a
`with` expression.

| Member | Type | Default | Meaning |
|---|---|---|---|
| `States` | `IReadOnlyList<JobState>` | required | The states this observer receives. |
| `AllTransitions` | `ObserverSubscription` (static) | every state | A subscription to every `JobState`, the usual starting point. |
| `WireName` | `string?` | `null` | Match only this job type's wire name. `null` matches every type. |
| `Queue` | `string?` | `null` | Match only this Queue. `null` matches every Queue. |

```csharp title="Subscriptions.cs"
// Every transition of every job.
var all = ObserverSubscription.AllTransitions;

// Only transitions of PaymentJob on the billing Queue.
var scoped = ObserverSubscription.AllTransitions with
{
    WireName = "PaymentJob",
    Queue = "billing",
};
```

The [Observers API](/docs/reference/observers-api) reference covers the `ITransitionObserver`
callback and the `ObserverContext` it receives.

### Observer pump options

`ConfigurePump` is optional and tunes the single pump that delivers to every observer.
Sensible defaults apply when you do not call it. The options are set on a mutable
`ObserverPumpOptions`.

| Option | Type | Default | Meaning |
|---|---|---|---|
| `MaxBatch` | `int` | `32` | Maximum transitions claimed per observer per poll. |
| `LeaseDuration` | `TimeSpan` | `60 seconds` | How long a delivery claim is held before another node may re-claim it; the redelivery window after a mid-delivery crash. |
| `DeliveryRetryPolicy` | `RetryPolicy` | `RetryPolicy.Default` | Backoff and attempt ceiling for a failed delivery before it dead-letters. |
| `PollInterval` | `TimeSpan` | `1 second` | How often the pump polls each observer's next batch. |
| `DeliveryTimeout` | `TimeSpan` | `30 seconds` | Maximum time one callback may run before the delivery is recorded failed and the pump moves on. A callback past the deadline is left to finish in the background and its exception is still observed, so it cannot crash the process. The worst-case latency behind a fully hung observer is `MaxBatch` times `DeliveryTimeout`, and only for that observer. |

## Job history policy

`JobHistoryPolicy` is a three-rung ladder, each rung adding to the one below. It gates whether
transition rows and failure detail are written; it never changes schema, so moving between
rungs is a configuration change and never a migration.

| Value | Records |
|---|---|
| `Off` | Nothing. No transition rows. |
| `Transitions` | Transition rows, with failure detail forced null. |
| `TransitionsAndFailureDetail` | The full log: transition rows plus clamped failure detail on a failing transition. This is the default, and the dashboard timeline works out of the box under it. |

You set the policy with `UseHistoryPolicy` on the builder. Its one live effect is the
startup guard that rejects observer registration under an `Off` policy. The Monitor reads the
effective policy directly from the store, so this value does not control how much the store
records; pass the same value the store is configured to record with. Because the default is
`TransitionsAndFailureDetail`, most applications never call `UseHistoryPolicy` at all.

## Environment switches

Two environment variables act as kill-switches independent of code. Both treat a value as
truthy when the trimmed string is `1`, or is `true`, `yes`, or `on` compared case-insensitively.

| Variable | Effect |
|---|---|
| `BACKWAVE_DISABLE_FAILURE_DETAIL` | Downgrades a `TransitionsAndFailureDetail` policy to `Transitions` at runtime, suppressing the recording of failure detail. Only the top rung is affected; transition rows are still recorded. This gates recording, not viewing, and guards against stack traces carrying secrets or PII into the host database. |
| `BACKWAVE_DASHBOARD_DISABLE_SENSITIVE_DATA` | Forces the dashboard's sensitive-data exposure off. Effective exposure is the dashboard's `ExposeSensitiveData` flag (default on) AND the absence of this variable, so either the flag or the variable can turn payload and sensitive-data exposure off. This is a Dashboard-package option; see the dashboard operations pages. |

### Overriding the clock

BackWave reads time through a `TimeProvider`. Register one in DI and it governs the clock for
the client, the operator, every Worker Group pump, and the observer pump. Absent one, each
defaults to `TimeProvider.System` and behavior is unchanged. This is how tests drive BackWave
under Virtual Time; see [Behavior over time](/docs/testing/behavior-over-time).

## Where to go next

- [Worker Groups & Dispatch](/docs/core-concepts/worker-groups): the runtime behavior the
  group options tune, including pool capacity and backpressure.
- [Dispatch Policies](/docs/reference/dispatch-policies): the full Strict and Weighted
  reference.
- [Observers API](/docs/reference/observers-api): the `ITransitionObserver` callback and
  `ObserverContext`.
- [Limits & Defaults](/docs/reference/limits-and-defaults): storage bounds and defaults in one
  place.
- [Retention & Purge](/docs/dashboard-operations/retention-and-purge): retention sweeping in
  operation.
- [Behavior over time](/docs/testing/behavior-over-time): driving BackWave under Virtual Time
  with a `TimeProvider`.
