Run retention & pruning
The module's retention option hard-prunes terminal run history (completed/failed/cancelled/dead) on an interval via StateStore.pruneTerminalRuns, per RetentionPolicy rules (maxAge and/or maxCount, optionally scoped to a namespace, tag, workflow set or search attribute) — without it, run history is kept forever by default and the durable tables grow unbounded.
Every run — completed, failed, cancelled, or dead — stays in the store forever by default. That's
the right default for a system whose whole point is auditable history: you can always trace a run back
through its checkpoints. But left alone indefinitely, durable_workflow_runs (and its child tables) only
grows, and the timer poller's per-tick status scans get linearly slower as it does. The retention module
option hard-prunes old terminal history on an interval so the tables stay bounded, while you keep control
over exactly what "old" means, per status.
Declaring retention: the module's retention option
DurableModule.forRoot({
store,
transport,
retention: {
sweepInterval: '1m',
batchSize: 1_000,
policies: [
{ statuses: ['completed', 'cancelled'], maxAge: '14d', maxCount: 200 },
{ statuses: ['failed'], maxAge: '90d' }, // keep failures longer for debugging
],
},
});DurableRetentionOptions has:
policies— the retention rules, one per (disjoint, per scope) status group. Required; omitretentionentirely to keep everything forever (the default).sweepInterval— how often the prune sweep runs. A number is ms; a string is anms-style duration ('1m','5m'— note'm'is minutes).0runs the sweep once, on boot only. Defaults to60000(1 minute).batchSize— max runs hard-deleted per batch, per policy, looped until a batch comes back short (so a big backlog drains over several batches instead of one giant delete). Defaults to1000.
Retention only runs on a driving operator instance — a dashboard/
dispatch-only instance (drive: false) or a thin worker (no store) never prunes, so you don't need to
worry about two instances racing each other's sweeps. It also requires a store adapter that implements
pruneTerminalRuns — every bundled adapter does (in-memory, Drizzle SQLite/Postgres, TypeORM, Prisma,
MikroORM); a custom adapter that omits it logs a warning and disables retention rather than failing boot.
RetentionPolicy: what gets pruned vs. kept
Within a policy's statuses, a run is kept only if it satisfies every bound you set on that policy,
and pruned the moment it violates any:
maxAge— prune runs whoseupdatedAt(≈ when they reached their terminal status) is older thannow - maxAge. A number is ms; a string is a duration ('7d','2w','90m'— again,'m'is minutes; use'30d'for roughly a month).maxCount— keep only themaxCountmost-recent (byupdatedAt) runs in the status set; prune everything past that.
Set one or both — with both, the most restrictive wins: a run is pruned once it's either past
maxCount or older than maxAge, whichever bites first.
Boot-time validation rejects a misconfigured policy
before it can silently do the wrong thing:
- every policy must set at least one of
maxAge/maxCount; statusesmay only name terminal statuses —completed,failed,cancelled,dead(TERMINAL_RUN_STATUSES); pruning a live status would race the engine and delete work in progress;- a status may appear in only one policy per scope — two policies over the same runs must not both
claim a status, so "most-recent N" is unambiguous (policies with different
scopes may share statuses); - a malformed duration string (
'7days','1 month') throws at boot instead of quietly never pruning.
Statuses you don't name in any policy are never pruned — e.g. the example above prunes
completed/cancelled at 14 days (capped at 200 rows) and failed at 90 days, but never touches dead
runs at all, so a poison-pill run stays inspectable indefinitely unless you add a policy for it.
Scoped policies: one tenant, one kind of run
Add a scope to confine a policy to the runs matching it — the identity axes of a run query:
namespace/namespaces, workflow/workflows, tag/tags (any of), and search-attribute
attributes. A scoped policy only ever selects, and for maxCount only ever counts, runs in its
scope, so "keep acme's newest 100" means 100 of acme's runs, not 100 overall.
retention: {
policies: [
// The default for everything…
{ statuses: ['completed', 'failed', 'cancelled'], maxAge: '30d' },
// …but chat turns are throwaway after a day,
{ statuses: ['completed', 'cancelled'], maxAge: '1d', scope: { tags: ['chat'] } },
// and one noisy tenant keeps only its newest 500 runs.
{ statuses: ['completed'], maxCount: 500, scope: { namespace: 'acme' } },
],
},Policies are applied one after another, so a run matched by several scopes is pruned by the strictest
one. A scope matches each run by its own row: children inherit their parent's namespace but not
its tags, so scope by namespace or by workflows (listing the child workflows too) when a whole run
tree has to age out together.
Purging on demand
Retention ages history out; sometimes you need everything for a scope gone now — a tenant deleted
their account, a feature's data is being wiped. WorkflowEngine.purgeRuns(scope, opts?) hard-deletes
every run matching a scope (same RunScope shape as above), in bounded batches, through the store API:
await engine.purgeNamespace('acme'); // every run a tenant/partition owns
await engine.purgeRuns({ tag: `tenant:${tenantId}` }); // or any other scope- Live runs in the scope are cancelled first (plain cancel, no saga undo), then deleted.
cancelLive: falseleaves live runs (and live descendants of finished ones) alone and deletes terminal history only. - Child runs are deleted with their root even when they don't match the scope themselves
(
children: falsedeletes exactly the matching runs). batchSize(default 500) bounds each round; each round is one bulkStateStore.deleteRuns(a custom store without it falls back to per-rundeleteRun).- An empty scope is rejected — it would delete every run in the store.
It returns how many runs were deleted, children included.
What pruning removes
A prune sweep hard-deletes — not archives, not soft-deletes — the run row plus its checkpoints,
signal waiters, and normalized search-attribute rows, in one transaction per batch, mirroring what
deleteRun does for a single run. Once
pruned, a run is gone from getRun/listRuns/the dashboard — there's no undo. Pruning is evaluated
per run, independent of parent/child relationships: unlike engine.deleteRun (which recursively
cascades to a run's children), pruneTerminalRuns only ever deletes runs matched by a policy's own
statuses/maxAge/maxCount — a pruned parent's still-young child run (or vice versa) is left alone on
its own terminal-status clock.
Dashboard implications
A pruned run's history simply isn't there anymore — you can't drill into a completed run from three
weeks ago if your policy caps completed at 14 days. This is deliberate: retention trades long-tail
auditability for a bounded table (and a bounded poller scan), so size the policy around how long you
actually need to debug or audit a given outcome, not around "just in case."
Operational guidance
Because maxAge/maxCount are set per status set, you don't have to apply the same retention to
every outcome. A reasonable starting point:
completed/cancelled— the "nothing to see here" outcomes. A couple of weeks ('14d') plus a row cap (maxCount) is usually enough to answer "did this run, and when" without the table growing forever.failed/dead— the outcomes you actually investigate. Keep these longer ('90d'or more, or a much highermaxCount) since a failure or dead-lettered run is exactly the history you'll want when debugging weeks later, and pruning it early loses the evidence.
Start conservative (longer maxAge, higher maxCount) and tighten once you've confirmed nothing depends
on the older history — pruning is irreversible, and there's no cost to leaving retention off entirely
until table growth actually becomes a problem.
Dead-letter queue
Cap crash-recovery with maxRecoveryAttempts so a poison-pill run moves to the terminal dead status instead of crash-looping forever, then route dead runs with engine.onDead or a DLQ workflow — an inline @DeadLetter() method, a per-workflow deadLetterWorkflow reference, or the module-level default — to alert, compensate, or queue for review.
Overview
How steps travel to workers. From an in-process event-emitter for zero-infra single-process handlers, to BullMQ/Redis, SQS, and a broker-less SQL transport for cross-process and cross-language steps.