Data retention

What ages out of the database, after how long, and how to read a retention sweep before anything is armed.

Administration → Data Retention → /data-retention

Some tables grow forever. Delivery logs, webhook events, poll failures, meter readings and audit streams accumulate at a rate set by the size of the portfolio, not by anything a user does. Data retention is the engine that ages those rows out on a policy, so storage stays bounded and queries stay fast.

It is deliberately hard to arm. Two surfaces:

SurfaceWhat it is
Retention PoliciesWhat ages out of which table, after how long, and whether the policy is armed.
Retention RunsEvery sweep, with what it removed — or in shadow mode, what it would have removed.

The safety model

There is no arm/disarm button in the app, and this is intentional. Policies are authored and armed by migration or explicit SQL, where the change leaves a reviewable trail. A browser toggle would collapse a three-switch safety model into one click.

Reading a policy

FieldMeaning
Policy keyThe policy's stable identifier.
MySQL tableThe table it ages.
ModeShadow or armed — the effective mode, after all three switches are resolved.
Evaluated by the sweeperWhether the sweeper considers this policy at all.
StrategyDelete rows removes whole rows; Blank columns keeps the row and nulls named columns.
Keep (days)Rows older than this are in scope.
Aged on columnThe date column the age is measured from.
Columns blankedFor the blank-columns strategy, which columns are cleared.
Extra filterAn additional predicate narrowing what is in scope.
Rows per statementChunk size — how many rows one statement touches.
Max statements per sweepCeiling on work per run, so a sweep cannot monopolise the database.
Swept / ResultWhen the policy last ran and how it ended.

Blank columns exists for tables where the row itself is still needed — a record of the event — but the payload inside it is not. It shrinks the table without breaking references.

Reading a run

Each sweep produces a run record per policy.

FieldMeaning
ModeShadow or armed for this run.
ResultSucceeded, failed, or refused.
Rows in scopeWhat matched the policy. In shadow mode this is what would have been removed.
Rows removedWhat was actually removed. Always 0 in shadow mode.
Chunks executedHow many statements ran.
Backlog remainingThe chunk ceiling was hit before the backlog cleared — more remains for the next sweep.
CutoffThe computed date; everything older was in scope.
Error / refusal reasonWhy a run failed or refused to proceed.

A refused run is not a failure. It means a guard fired — the policy was misconfigured, the scope was implausibly large, or a switch was off — and the engine declined rather than guessing.

Bringing a policy into service

  1. Author it in shadow

    The policy is created disarmed. Nothing is deleted.

  2. Let it sweep and read the runs

    Check rows in scope against what you expect. A count far larger than anticipated usually means the wrong date column or a missing filter.

  3. Check the cutoff date

    Confirm the cutoff lands where the keep-days value implies.

  4. Confirm the data is genuinely disposable

    Check nothing reports off it, and that any regulatory retention period is longer than the keep-days value, not shorter.

  5. Arm deliberately

    By migration, with the shadow evidence in the change description.

  6. Watch the first armed runs

    Expect a backlog over several sweeps rather than one large deletion.

Common problems

SymptomCause
Runs succeed but remove nothingShadow mode. Check all three switches.
Rows in scope is zeroWrong date column, or the extra filter excludes everything.
Backlog never clearsChunk ceiling too low for the table's growth rate. Raise max statements per sweep.
Run refusedRead the refusal reason — a guard fired on purpose.
Table size unchanged after a large removalSpace is reclaimed internally first; the file shrinks later.
A policy is not listedIt is not enabled, so the sweeper never evaluates it.