What a finished execution leaves behind

Copy Markdown View Source

An execution that has ended keeps three things in your store: its row in the executions table, the last position blob written on that row, and, if your adapter keeps one, its whole input log. The row is history. The position blob and the log are not needed by anything in this package once the execution has ended, and the log holds your own event data, verbatim.

This page says what StatifierPersistence.Retention.prune/3 clears, which rows you may delete yourself for a finished execution, and which you must not. The schema declares no foreign keys, so the database will not stop a wrong delete. This page is the only guard.

The design is ADR-0016.

Pruning

cutoff = DateTime.add(DateTime.utc_now(), -90, :day)

{:ok, %{executions: pruned, position_blobs: blobs, inputs: inputs}} =
  StatifierPersistence.Retention.prune(store, cutoff)

prune/3 selects every execution whose status is :completed, :failed or :cancelled and whose ended_at is before cutoff. For each one it sets position_blob to NULL and deletes all of its input log rows. The row itself stays, with its status, failure, metadata, answer and ended_at.

  • The cutoff is yours. It is a DateTime, and prune/3 raises on a number of days, a Duration or a Date. This package has no retention window and no default. How long you keep a finished execution's leftovers is your policy, and you run the prune when that policy says to.
  • An execution with no ended_at is never pruned. That includes a row that was already finished before migration V08 added the column: its end time is not stored anywhere. It gets a stamp only when a later terminal write reaches it.
  • A stamped row that is not terminal is never pruned. A stamp stays on a row that a later write put back to :active, and that execution can still take a step.
  • It works in batches and can be repeated. batch_size: (default 500) bounds each transaction. Each batch commits on its own, so a call that fails part-way leaves the earlier batches done. Calling again with the same cutoff carries on, and a call with nothing left to do answers zeros. This holds when nothing encloses the call; see "Pruning inside your own transaction" below for what changes when something does.
  • It needs an adapter that declares it. Both shipped adapters do. The in-memory adapter keeps no input log, so it clears position blobs only. An adapter of your own declares it by exporting supports_execution_pruning?/1 and prune_executions/4; one that does not gets {:error, :execution_pruning_unsupported}, and StatifierPersistence.Storage.execution_pruning_supported?/1 tells you in advance.

Pruning one partition

If your tables carry a column of your own that partitions them, placed with :leading_columns on use StatifierPersistence.Ecto, scope: confines a prune to the rows that hold it:

StatifierPersistence.Retention.prune(store, cutoff, scope: [tenant_id: tenant_id])

scope: is a keyword list of column equalities. Every statement each batch runs carries all of them: the selection, the input log check inside it, the input log delete and the position blob update. So a prune you run inside one partition's transaction reads and writes no row of another.

  • The columns must be your :leading_columns. The Ecto adapter raises ArgumentError for any other column before it runs a statement.
  • A scope is never empty and never nil. prune/3 raises on scope: [], so a scope your code computed to nothing cannot prune every partition. It also raises on a nil value, because an equality with NULL matches no row. To prune across every partition, leave scope: out.
  • The in-memory adapter cannot scope. Its records have no columns of yours, so it answers {:error, :unscoped_adapter} for any scope: and clears nothing.

Pruning inside your own transaction

prune/3 called with nothing enclosing it commits each batch on its own. Called inside a transaction of your own, its whole drain - every batch - runs as that one transaction, and commits or rolls back with it. batch_size: then bounds each batch's statements, not the transaction, so a host that opens a transaction to set a partition's context before any write and calls prune/3 there loses the short-transaction bound batch_size: exists for.

For one bounded transaction per batch, call prune/3 with single_batch: true inside each of your own transactions, and call again while more? is true:

def prune_tenant(store, cutoff, tenant_id) do
  batch =
    Repo.transaction(fn ->
      # set the tenant's context for this transaction here, then:
      opts = [scope: [tenant_id: tenant_id], single_batch: true]

      case StatifierPersistence.Retention.prune(store, cutoff, opts) do
        {:ok, counts} -> counts
        {:error, reason} -> Repo.rollback(reason)
      end
    end)

  with {:ok, %{more?: true}} <- batch do
    prune_tenant(store, cutoff, tenant_id)
  end
end

It answers {:ok, counts} for the last batch, with more?: false, or the first {:error, reason}. Every batch before that one has committed, so calling it again carries on.

more? is true when the batch took as many executions as batch_size: allows, so another call may find more, and false when it took fewer - the same point where the default drain stops on its own. When the last due batch holds exactly batch_size: executions, that call answers more?: true and the next one answers zeros with more?: false, so a loop that stops at more?: false makes one extra call that clears nothing. A row another transaction holds locked is skipped, not waited on, so it is left for a later call either way.

After a prune, Executions.inputs/2 answers {:ok, []} for that execution, which is also what an execution that took no input answers. A load of its position answers {:error, :execution_position_missing}, which is also what an execution that failed at creation answers. No read tells you an execution was pruned. If something you run reads input logs, for example a replay tool, decide which executions it may read from their ended_at and your own cutoff.

Rows you may delete for a finished execution

"Finished" here means the same thing it means to prune/3: the status is :completed, :failed or :cancelled, and it has an ended_at.

RowWhy it is safe
Its input log rows (the inputs table, by execution_id)Nothing in this package reads the log; Executions.inputs/2 is a diagnostic read for you. Delete all of an execution's rows or none of them. prune/3 does this for you.
Its position_blob, set to NULL on the executions row (not the row itself)Nothing in this package loads a finished execution's position. step/5, fail/4 and cancel/3 discard a finished execution before loading anything, and migrate/4 refuses one. prune/3 does this for you.
A positions row, keyed by session, for a session you will never resumeThis package cannot tell when a session is done. The positions table has no end stamp and no link to an execution, so prune/3 never touches it. Deleting the row also releases the chart pin it holds, which can make that chart retirable (see "Retiring a chart" in the README).

Rows you must not delete

RowWhat breaks
The execution row itselfThe drained query (Executions.executions_on/2) stops counting it. A parent that has not settled yet reads a child's status and answer from this row. A re-driven child create would no longer find the child, and would start it again from the beginning. And the id becomes free to reuse, for an execution nothing would tell apart from the old one.
A charts rowRetire a chart with Executions.retire_chart/4, which refuses while anything still needs it. A deleted row loses its tombstone: fetch_chart/2 answers :chart_not_found rather than the retired arm, and a later save_chart/3 brings the chart back. If you read a chart back through fetch_chart/2 to resume an execution, nothing on that hash can be resumed.
The input log or position of an execution that has not finishedThe execution can still take a step. With its position gone, the step fails with :execution_position_missing. With its log gone, the next input is written at ordinal 0 again, and the log reads as if the execution had started there.
Some, but not all, of an execution's input log rowsA log with a gap looks complete and is not. A replay built from it replays a different execution.

What pruning does not do

  • It keeps the answer and the metadata. outcome_blob, failure and metadata stay on the row, because a parent may still need the answer. Metadata should hold host identities only, never personal data (see ADR-0006). If your chart's answers carry personal data, pruning does not remove it.
  • It does not touch keys or copies. If you encrypt the blob columns (:blob_type), pruning removes the ciphertext from the live rows. It does not rotate or destroy a key, and it does not reach your backups.
  • It does not cascade. A parent and its durable children are each pruned on their own ended_at. Either order is safe, because the rows stay.
  • It has nothing to clear from the trace. This package emits telemetry and stores none of it.