Configuration Options
An option-by-option reference for AddBackWave, Worker Group options, retry and retention policies, the observer block, and the environment switches.
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.
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. |
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. |
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. Pumpsmust be at least one. A group withPumpsbelow one reportsWorker 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 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. |
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. |
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. |
Retention | RetentionPolicy? | RetentionPolicy.Default | Keep-then-purge policy for terminal jobs. Set to null to disable retention sweeping entirely. See 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.
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.
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 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.
// 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 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.
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 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.
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. |
// 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 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.
Where to go next#
- Worker Groups & Dispatch: the runtime behavior the group options tune, including pool capacity and backpressure.
- Dispatch Policies: the full Strict and Weighted reference.
- Observers API: the
ITransitionObservercallback andObserverContext. - Limits & Defaults: storage bounds and defaults in one place.
- Retention & Purge: retention sweeping in operation.
- Behavior over time: driving BackWave under Virtual Time
with a
TimeProvider.
Found a problem on this page? Report an issue