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.

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:

MethodSignaturePurpose
UseStoreUseStore(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.
UseRegistryUseRegistry(JobRegistry registry)Supply a hand-built registry. Required unless you call UseJobs.
UseRegistry (factory)UseRegistry(Func<IServiceProvider, JobRegistry> factory)Same, resolved from DI.
UseJobsUseJobs(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.
UseHistoryPolicyUseHistoryPolicy(JobHistoryPolicy policy)Set the history policy used to validate observer registration at startup. Defaults to TransitionsAndFailureDetail. See Job history policy.
AddWorkerGroupAddWorkerGroup(WorkerGroupOptions options)Add one Worker Group. Call once per group.
AddObserversAddObservers(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.
  • 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 page covers the runtime behavior these options tune.

OptionTypeDefaultMeaning
NamestringrequiredUnique within one AddBackWave call. Appears in health, metrics, and logs.
PolicyDispatchPolicyrequiredWhich Queues the group serves and how it shares effort across them. See Dispatch policies.
PoolSizeint20Maximum 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.
Pumpsint1Independent 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.
MaxClaimBatchint32Maximum jobs claimed in a single poll. In practice capped at PoolSize, since a group never claims more than it can run.
MaxOutcomeBatchint?nullMaximum 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.
PollIntervalTimeSpan1 secondHow often the group polls for new work. A wake-up hint only brings a poll forward; bounded polling is the correctness mechanism.
MaxPollIntervalTimeSpanTimeSpan.ZeroLongest 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.
MaintenanceIntervalTimeSpan5 secondsCadence 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.
LeaseDurationTimeSpan60 secondsHow 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.
HeartbeatIntervalTimeSpan?nullHow often in-flight leases are renewed. When unset, defaults to one third of LeaseDuration, which survives a couple of missed heartbeats.
RetryPolicyRetryPolicyRetryPolicy.DefaultBackoff schedule and attempt ceiling before a job dead-letters. A Job type that carries [Retry] overrides it. See Retry policy.
RetentionRetentionPolicy?RetentionPolicy.DefaultKeep-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.

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.

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

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

MemberTypeDefaultMeaning
MaxAttemptsint10Maximum Attempts before the job dead-letters instead of retrying.
BackoffFunc<int, TimeSpan>RetryPolicy.DefaultBackoffDelay before the next Attempt, given the number of the Attempt that just failed.
RetryPolicy.DefaultRetryPolicy (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.

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.

MemberTypeDefaultMeaning
KeepSucceededTimeSpan24 hoursHow long Succeeded and Cancelled jobs stay queryable before sweep.
KeepDeadLetteredTimeSpan14 daysHow long Dead-Lettered and Quarantined jobs stay queryable before sweep.
RetentionPolicy.DefaultRetentionPolicy (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.

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.

MemberTypeDefaultMeaning
StatesIReadOnlyList<JobState>requiredThe states this observer receives.
AllTransitionsObserverSubscription (static)every stateA subscription to every JobState, the usual starting point.
WireNamestring?nullMatch only this job type's wire name. null matches every type.
Queuestring?nullMatch only this Queue. null matches every Queue.
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 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.

OptionTypeDefaultMeaning
MaxBatchint32Maximum transitions claimed per observer per poll.
LeaseDurationTimeSpan60 secondsHow long a delivery claim is held before another node may re-claim it; the redelivery window after a mid-delivery crash.
DeliveryRetryPolicyRetryPolicyRetryPolicy.DefaultBackoff and attempt ceiling for a failed delivery before it dead-letters.
PollIntervalTimeSpan1 secondHow often the pump polls each observer's next batch.
DeliveryTimeoutTimeSpan30 secondsMaximum 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.

ValueRecords
OffNothing. No transition rows.
TransitionsTransition rows, with failure detail forced null.
TransitionsAndFailureDetailThe 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.

VariableEffect
BACKWAVE_DISABLE_FAILURE_DETAILDowngrades 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_DATAForces 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#

Found a problem on this page? Report an issue