Skip to content

Retention and Purging ​

How to control how long completed, failed, and cancelled runs stay in the database.

Why Terminal Rows Accumulate ​

By default, a terminal run stays in cyclone_runs with no time limit. It also stays in cyclone_steps. The terminal states are completed, failed, and cancelled. Over time, these rows accumulate and:

  • Increase table bloat.
  • Vacuum slows down.
  • Slow down monitoring queries that scan cyclone_v_runs.
  • Grow disk usage.

Configure retention to delete terminal rows after a defined period.

A paused run is not terminal. Retention does not delete it until it ends.

A terminal child run stays until its parent run completes or is deleted. A running, paused, or failed parent run can still need its result. A retry of the parent run reuses the child run. A cancelled parent run starts again when a retry of its own parent reopens it.

Default Retention Behavior ​

With no retention configuration, Cyclone keeps terminal runs forever. You must configure retention, or you must call PurgeTerminalRuns manually.

WithRetention(d): Per-Step Retention ​

Set a retention period on a step definition:

go
var QuickJob = cyclone.NewStep("quick_job", quickFn,
    cyclone.WithRetention(24*time.Hour), // keep terminal run for 24 hours
)

Or set it on one enqueue:

go
cyclone.Enqueue(ctx, client, quickJob, in,
    cyclone.WithRetention(72*time.Hour),
)

Cyclone stores the retention duration in cyclone_steps.retention. When a run becomes terminal, it is eligible for purge after finished_at + max(retention across steps).

WithRetentionSweep: Automatic Background Purging ​

Configure the client to start a background sweeper. The sweeper deletes eligible terminal runs on an interval:

go
client, _ := cyclone.NewClient(pool, mux,
    cyclone.WithWorkers(8),
    cyclone.WithRetentionSweep(cyclone.RetentionConfig{
        DefaultAge: 30 * 24 * time.Hour,
        Interval:   5 * time.Minute,
        BatchSize:  1000,
    }),
)

client.Start includes the sweeper. On every interval the sweeper does its own bounded delete. The delete honors each step's WithRetention. A step with no retention uses DefaultAge (COALESCE(retention, DefaultAge)). The sweeper deletes a run only when every step has passed its retention period.

The sweeper does not call PurgeTerminalRuns. That function is a separate, flat age cutoff. See Manual PurgeTerminalRuns. PurgeTerminalRuns ignores per-step retention entirely.

Start with a conservative batch size (500–1000). A batch that is too large can cause a long transaction. That transaction can block vacuum.

Manual PurgeTerminalRuns ​

Call it directly for one-off purges or custom scheduling:

go
deleted, err := client.PurgeTerminalRuns(ctx, 7*24*time.Hour, 500)
if err != nil {
    return fmt.Errorf("purge: %w", err)
}
fmt.Printf("deleted %d terminal runs\n", deleted)

PurgeTerminalRuns applies one flat age cutoff. It repeats bounded deletes internally until no eligible rows remain. It then returns the total deleted count.

Batch Sizing ​

Each PurgeTerminalRuns call loops. Each pass issues one bounded DELETE per batch until a short batch drains the eligible set. Each batch is its own implicit transaction. The call as a whole is not atomic. Large batches hold each transaction open longer. That can:

  • Delay autovacuum on the deleted pages.
  • Cause lock contention on cyclone_runs under heavy insert load.

Recommended: 500–2000 rows per batch. Tune the size from your row size and your vacuum settings.

Impact on Outputs ​

Cyclone stores step outputs in cyclone_steps.output. A purge of a run deletes all of its step rows. That delete includes their outputs. After the purge, run.Result, run.Output, and cyclone.OutputNode return ErrRunNotFound.

If you need the outputs after retention, copy them to your application tables. Use ctx.CompletionTx(). Do this before the step completes, or before the retention window closes.

Signals and Schedule Fires ​

cyclone_signals and cyclone_schedule_fires have no retention of their own. Both live as long as their run. The ON DELETE CASCADE from cyclone_runs deletes them when retention purges the run. This includes consumed signal rows. Cyclone keeps those rows for audit until then.

Mailbox size does not grow without bound. Signal refuses an insert once the mailbox holds as many rows as the declared wait can use. The limit is 1 for WaitSignal and WaitAny. The limit is N for WaitN. A delivery that arrives after the wait has completed is not persisted. Cyclone bounds mailbox depth per run. It does not bound that depth per signal sender.

The cascade cost does grow when the child tables are large. Both tables therefore have a plain index on run_id. Without that index, retention scans those tables for each deleted run. Purge cost then tracks total mailbox volume and fire volume. It does not track batch size.

Relationship to Debugging and Auditing ​

Shorter retention means less history for:

  • Post-mortem debugging of failed runs.
  • An audit of which jobs executed, and of their outputs.
  • Replays of failed workflows from old step inputs.

You can:

  • Keep failed and cancelled runs longer than completed runs. Those runs are more likely to need investigation.
  • Export job history to an analytics table before the purge. Do this when you need a permanent audit trail.

Operations Checklist ​

  • Choose a retention period for your debugging needs and your compliance needs.
  • Set WithRetention on steps. Or configure a default with WithRetentionSweep.
  • Test the purge in a staging environment before you enable it in production.
  • Monitor table sizes in pg_stat_user_tables after you enable the purge.
  • Make autovacuum reclaim deleted space often enough. See Database Maintenance.

Cyclone stores each run in Postgres.