# Retention & History Purge

A terminal job does not live forever. Once a job reaches Succeeded, Cancelled, Dead-Lettered, or Quarantined, a keep window starts running, and when it elapses a background sweep deletes the job and everything hanging off it. That keeps the store, the dashboard, and the Monitor bounded without an external cleanup job. Three independent levers shape this lifecycle, and the most common mistake is to conflate them: the **Job History Policy** decides whether transition history is recorded at all, the **per-job transition cap** bounds how many entries a single job keeps over its life, and the **retention window** decides how long a whole terminal job survives before it is purged.

## Three levers, kept separate

Each lever acts at a different point in a job's life. Recording happens at write time, the per-job cap applies as a job churns, and retention deletes the whole job after it goes terminal.

| Lever | What it controls | When it acts | Where you set it |
|---|---|---|---|
| Job History Policy | Whether transition rows (and failure detail) are recorded | At write time, as transitions occur | Store options (`HistoryPolicy`) |
| Per-job transition cap | How many transitions a single job keeps over its life | Continuously, as a job moves between states | Store options (`Bounds`) |
| Retention window | How long a terminal job and its log are kept before deletion | After a job reaches a terminal state | Worker group options (`Retention`) |

The recording policy decides whether a row is ever written; the transition cap decides how many survive within one job; retention decides when the whole job and its rows are deleted. They are orthogonal gates on the same data.

## Retention windows

Retention is expressed as a `RetentionPolicy` (in the `BackWave.Core` namespace). It carries two keep windows, one per terminal class, and the retention clock for a job always starts at the instant the job reached its terminal state, never when it was enqueued.

| Property | Default | Covers these terminal states |
|---|---|---|
| `KeepSucceeded` | 24 hours | Succeeded **and** Cancelled |
| `KeepDeadLettered` | 14 days | Dead-Lettered **and** Quarantined |

The property names mention only one state from each pair, so it is easy to assume Cancelled and Quarantined are unmanaged. They are not. `KeepSucceeded` governs both the clean-success class (Succeeded and Cancelled), and `KeepDeadLettered` governs both the failure class (Dead-Lettered and Quarantined). Failures get a longer leash than successes by default, fourteen days versus one, so that someone can still inspect a dead-letter or a quarantine well after it happened.

`RetentionPolicy.Default` is the instance carrying those two defaults. Non-terminal jobs are never eligible for purge; only a job that has reached one of the four terminal states is ever swept.

## Configuring retention on a worker group

Retention is configured per worker group, on the `Retention` property of `WorkerGroupOptions` (in `BackWave.Hosting`). It is on by default, set to `RetentionPolicy.Default`. To use your own windows, assign a `RetentionPolicy` with object-initializer syntax:

```csharp title="Program.cs"
bw.AddWorkerGroup(new WorkerGroupOptions
{
    Name = "emails",
    Policy = new DispatchPolicy.Strict("emails"),
    Retention = new RetentionPolicy
    {
        KeepSucceeded = TimeSpan.FromHours(6),
        KeepDeadLettered = TimeSpan.FromDays(30),
    },
});
```

To turn the purge sweep off entirely on a group, set `Retention` to `null`:

```csharp title="Program.cs"
bw.AddWorkerGroup(new WorkerGroupOptions
{
    Name = "audit",
    Policy = new DispatchPolicy.Strict("audit"),
    Retention = null, // no sweeping: terminal jobs and logs accumulate forever
});
```

Disabling retention means unbounded growth on that store: terminal jobs and their transition logs accumulate with nothing to remove them. Only do this when an external process prunes the tables, or for short-lived and test setups.

> Retention lives on the worker group, not on a global switch, so different groups can carry different windows. Note that the sweep is not scoped to one group's queues: any group with retention enabled sweeps every eligible terminal job in the store by its own windows. If you run several groups with different windows, the shortest effective window wins for any given job. Setting retention consistently across groups avoids surprises.

## The purge sweep

The deletes run as part of each worker group's background maintenance, not on the claim path. The cadence is `MaintenanceInterval` on `WorkerGroupOptions`, which defaults to 5 seconds; the same tick that expires lapsed leases and mints recurring schedules also purges retained history. (For contrast, `PollInterval`, the claim poll, defaults to 1 second; keep maintenance slower so claim polls stay a cheap fast path.)

```csharp title="Program.cs"
new WorkerGroupOptions
{
    // ...
    MaintenanceInterval = TimeSpan.FromSeconds(10), // how often the purge sweep runs
}
```

On each tick, retention runs two passes when it is enabled: one over the Succeeded-and-Cancelled class using `KeepSucceeded`, and one over the Dead-Lettered-and-Quarantined class using `KeepDeadLettered`. A job is eligible only when it is in the matching terminal class and its terminal instant is at or before `now` minus the keep window. Eligible jobs are deleted oldest-terminal-first.

Each pass is bounded: it deletes at most 500 jobs, then, if it filled that batch, schedules another pass, repeating until a pass deletes zero. Bounded batches repeated to drain mean a large backlog is cleared steadily over several ticks rather than in one long lock. A missed or delayed maintenance tick only postpones cleanup by one interval; it never affects correctness. A slower `MaintenanceInterval` simply runs the sweep less often.

### What gets deleted, and what is kept

When a job is purged, it is gone, not archived. The job row is deleted, and its entire transition log is deleted with it: the log lives exactly as long as the job. The job's output blob, which lives on the job row, goes with it too.

After a job is purged, reading it returns nothing, an unknown job rather than an empty-but-present record, and its timeline reads as an empty list. So a purged job disappears from the dashboard and from the Monitor entirely. Size your keep windows to your inspection needs: the 14-day default on the failure class exists precisely so dead-letters and quarantines stay inspectable long enough for a post-mortem. Reading that history while it is still retained is covered on [The Monitor API](/docs/dashboard-operations/monitor-api).

### Workflow jobs are kept as a unit

A job that belongs to a [Workflow](/docs/core-concepts/workflows) (a Pro grouping over dependent jobs) is not purged on its own terminal instant. The whole Workflow is retained as a unit: a member becomes eligible only once every member has reached a terminal state, and then the keep window starts from that drain point, the latest member's terminal instant, rather than from each member's own terminal time. This keeps the Workflow graph coherent for its whole life. When the last member is purged, the now-orphaned Workflow identity is dropped with it. This behavior is fixed; there is no separate knob for it.

## Recording: the Job History Policy

Retention can only delete what was recorded in the first place, and what gets recorded is governed by the Job History Policy, a `JobHistoryPolicy` (in `BackWave.Storage`). It is a ladder; each rung adds to the one below.

| Value | What it records |
|---|---|
| `Off` | Nothing. No transition rows are written by any operation. |
| `Transitions` | Transition rows, but failure detail is forced to null. |
| `TransitionsAndFailureDetail` | The default. Transition rows plus the captured failure detail on a failing transition. The dashboard timeline works out of the box at this rung. |

The policy gates writes, never schema. The transition table always exists, so changing the policy is a configuration change and never a migration. It is an input to a run and does not affect determinism.

This is where recording and retention meet. With history `Off`, no transition rows are ever written, so there is nothing in the log for retention to purge; retention still deletes the terminal job rows on the same windows, there is simply no timeline attached to them.

### Configuring the policy in two places

The policy is set in two places, and they must agree. The store options are the source of truth that the store actually records by; each adapter exposes a `HistoryPolicy` property, all defaulting to `TransitionsAndFailureDetail`:

```csharp title="Program.cs"
// Write side: the source of truth the store records by.
new PostgresStoreOptions
{
    ConnectionString = "...",
    HistoryPolicy = JobHistoryPolicy.Transitions, // record transitions, drop failure detail
}
```

The registration side carries the same value through `UseHistoryPolicy` on the BackWave builder:

```csharp title="Program.cs"
bw.UseHistoryPolicy(JobHistoryPolicy.Transitions);
```

`UseHistoryPolicy` does not change what the store records, the store options do that. The Monitor reads the effective policy straight from the store, so the two cannot drift on the read side. `UseHistoryPolicy` now only feeds a startup guard: registering a Transition Observer while history is `Off` fails fast, because with no transitions recorded there is nothing for the observer to deliver. Pass it the same value you set on the store; most applications never need to call it.

### Suppressing failure-detail capture without a redeploy

There is an environment kill-switch for the top rung. Setting `BACKWAVE_DISABLE_FAILURE_DETAIL` to a truthy value downgrades an effective `TransitionsAndFailureDetail` to `Transitions`: transitions still record, but failure-detail capture is suppressed. This guards against stack traces carrying secrets or PII landing in the host database.

```bash
BACKWAVE_DISABLE_FAILURE_DETAIL=true
```

Truthy values are `1`, `true`, `yes`, and `on` (case-insensitive, trimmed). The switch only downgrades the top rung; it never turns history fully off. It gates recording, not viewing, viewing captured detail in the dashboard is governed separately by the `ViewSensitiveData` permission.

## The per-job transition cap

The third lever bounds history within a single job's life, independent of retention. It lives on `StoreBounds` (in `BackWave.Storage`), set through the store options' `Bounds` property.

| Bound | Default | Behavior |
|---|---|---|
| `MaxTransitionsPerJob` | 64 | How many transition-log entries one job keeps over its life. When a state change would exceed the cap, the oldest entry is dropped, newest kept. |
| `MaxFailureDetailBytes` | 8192 | Failure-detail size cap. Over-limit detail is truncated, byte-exact, never splitting a UTF-8 code point; it is never rejected. |
| `MaxPurgeBatch` | 500 | The most jobs a single purge sweep pass deletes before scheduling another pass. |

So a job that retries many times keeps only its newest 64 transitions, with the oldest aging out as it churns. The whole job and its surviving transitions are then deleted when retention's keep window elapses after it goes terminal.

## Operational implications

| Situation | Effect |
|---|---|
| Keep window elapses | The job, its full timeline, and its output vanish from the dashboard and Monitor. There is no archive. |
| `Retention = null` | No sweeping on that group; terminal jobs and logs grow without bound. |
| History `Off` | The per-job timeline is empty. The Monitor exposes an explicit "recording disabled" signal so the UI shows that state rather than a blank that looks broken. |
| Transition Observer registered | History must be at least `Transitions`. Turning history `Off` makes the startup guard fail fast. |
| Slower `MaintenanceInterval` | The sweep runs less often. Cleanup is delayed by at most one interval; correctness is unaffected. |
| Multiple worker groups | Retention is per-group config. Each enabled group sweeps all eligible terminal jobs by its own windows; the shortest window wins. |

How the Monitor surfaces the effective policy, including the recording-disabled signal, is covered on [The Monitor API](/docs/dashboard-operations/monitor-api).

## Where to go next

- [The Monitor API](/docs/dashboard-operations/monitor-api): reading a job's transition timeline and failure detail while it is still retained.
- [The Monitor API](/docs/dashboard-operations/monitor-api): how the Monitor reports the effective history policy and the recording-disabled state.
- [The Dashboard](/docs/dashboard-operations/overview#what-operators-can-do): requeue, cancel, and the other state-machine transitions that move jobs toward (or away from) terminal.
- [Workflows](/docs/core-concepts/workflows): the Pro grouping whose members are retained as a unit until the whole graph drains.
- [Scheduling](/docs/core-concepts/scheduling): the background maintenance tick that the purge sweep shares with recurring-schedule minting.
