Skip to main content

Data Retention

REQ-034 (#151). The first retention mechanism in the platform, scoped deliberately to run_events alone.

Why this table first

run_events takes one row per streamed part, so a single long assistant turn writes hundreds. It grows faster than every other table combined, it is append-only by design — the port has no delete — and nothing has ever removed a row from it. Storage and index size grew monotonically with usage and never came back.

Doing one table properly establishes the pattern the rest reuse, rather than inventing a general retention framework before there is a second case to generalise from. Two things here are the pattern and not this table's specifics:

  • Retention is configuration with a documented default, never a constant.
  • A sweep is bounded and reports what it removed, so a caller drains a backlog instead of holding one lock.

The retention period

Default: 90 days. DEFAULT_RUN_EVENT_RETENTION_DAYS in backend/src/retention/index.ts.

It is provisional. #151 asks the product owner which it should be — 30 days, 90, or indefinite-until-configured — and that is a compliance answer, not a technical one. 90 days is the level the table's own purpose implies: long enough that a customer investigating last quarter's run still has its log, short enough that the largest table in the schema does not grow without bound. Nobody has agreed to it, and the constant says so.

A default exists at all so that an unconfigured deployment prunes something rather than nothing. The failure direction should be "an old log was removed" rather than "the disk filled".

import { createPostgresRunEventPruner } from "@forge/agentkit/adapters/postgres";
import { cutoffFor, drain, DEFAULT_RUN_EVENT_RETENTION_DAYS } from "@forge/agentkit/observability";

const pruner = createPostgresRunEventPruner(sql);
const result = await drain(pruner, {
olderThan: cutoffFor({ now: Date.now(), retentionDays: DEFAULT_RUN_EVENT_RETENTION_DAYS }),
limit: 5_000,
maxBatches: 200,
});
// result.drained === false means the ceiling was hit and there is more to do.

The safety predicate — never prune a live run

Events belonging to a non-terminal run are never deleted, whatever their age. A running, queued, waiting-for-* or retry-pending run can still be reconciled against its log — that is precisely what #93/#94 exist to provide — so deleting its events breaks crash recovery for a run that is still alive.

Age is irrelevant to that, and the case that makes it obvious: a run waiting on a human approval for four months is old and still needs its log.

PRUNABLE_RUN_STATUSES is ('completed', 'failed', 'cancelled'), exported so the SQL and the documented rule cannot drift. A test enumerates every status not in that list and asserts none is prunable — derived from RUN_STATUSES rather than hand-written, so a new non-terminal status is covered the day it is added.

The join lives inside the candidate subquery, so a non-terminal run's rows are never even selected. They cannot be deleted by a later mistake in the outer statement.

The sweep

DELETE FROM run_events
WHERE ctid IN (
SELECT e.ctid FROM run_events e
JOIN runs r ON r.tenant_id = e.tenant_id AND r.id = e.run_id
WHERE e.created_at < $1 AND r.status IN ('completed','failed','cancelled')
ORDER BY e.created_at
LIMIT $2
)
RETURNING ctid

ctid IN (SELECT … LIMIT n). The bound has to be on the rows selected, and Postgres has no DELETE … LIMIT. Selecting ctid — the physical row locator — lets the subquery do a bounded, index-driven scan and the delete touch exactly those rows. A correlated tuple list on (tenant_id, run_id, sequence) is a much larger comparison per row for no benefit.

ORDER BY created_at. Oldest first, so a bounded sweep makes progress at the end of the table that will never come back. Without it the sweep nibbles wherever the scan happened to reach, and genuinely ancient rows can survive arbitrarily many bounded runs.

No row locking, deliberately. FOR UPDATE SKIP LOCKED is absent: two concurrent sweeps selecting overlapping ctids is harmless, because the second DELETE matches no row for the ones already gone and simply reports a smaller count. Adding locks would serialise the sweeps and hold them across the join — exactly the blocking the batching exists to avoid. Idempotency comes from a delete being a no-op on an absent row, not from exclusion.

A non-positive limit is answered without touching the database. A maintenance loop with a misconfigured batch size should not generate load.

The index

Migration 0021_run_events_retention adds run_events (created_at, tenant_id, run_id).

The primary key is (tenant_id, run_id, sequence), and created_at is not a prefix of it — so an age-based sweep without this index is a sequential scan over the largest table in the schema.

The two trailing columns are the join key the safety predicate needs, so the candidate scan can be index-only with no heap fetch per row. On a table where the interesting case is "millions of rows, most of them prunable", that is the difference between a sweep that finishes and one that thrashes the buffer cache.

Verified with EXPLAIN against a real server, by name — not asserted as "not a Seq Scan", which would pass on any other index the planner happened to pick. The test inserts 20,000 rows and runs ANALYZE first, because on a small table a sequential scan genuinely is cheaper and the planner is right to choose it; asserting otherwise would be asserting the planner is wrong.

RunEventLog gains nothing

The port is append-only on purpose. A run's event log is the record crash recovery reconciles against, and a delete on the port would put deletion within reach of ordinary run code. Pruning lives on a separate maintenance surface — RunEventPruner — so that is impossible by construction rather than by convention.

A test asserts the interface declaration contains no delete, remove, prune, truncate or purge, and also that it still contains append, listAfter and latestSequence — so it cannot pass by the interface having been renamed away.

Not in this SPEC

  • No scheduler. Pruning is a callable operation. Wiring it to a cron or the worker's maintenance loop belongs with the worker entrypoint (#107), and coupling them here would have blocked this on that.
  • No audit record of what was removed. #151 raises it as an open product question: should a customer be shown that deletion happened? Left unanswered rather than guessed, because the answer determines whether the sweep needs a durable write of its own.
  • Only run_events. Every other table still grows without bound. This is the pattern, not the coverage.

Two coincidental passes, from sabotage

Both worth recording, because a test that passes for the wrong reason is worse than a missing one.

The ordering test. It seeded the older run first, and PGlite's scan returned rows in insertion order — so "oldest first" held by accident and the assertion was about the fixture rather than the query. Removing ORDER BY left every test green. The fixture now inserts the newer run first, so insertion order is the opposite of age order.

The down-migration test. It called rollback(), which reverses every migration and drops the run_events table, taking the index with it. Replacing the down statement with SELECT 1 left the test green: it was asserting that dropping a table removes its indexes, which Postgres guarantees and nobody doubted. It now runs this migration's own down statements and asserts the table survives.

Connections — added by #261

A tenant's third-party credentials are personal data of the sharpest kind: each row is somebody's access to somebody else's system. ConnectionStore therefore has two removals, and the difference matters.

revoke is soft. The row stays with a revoked_at, and the connection stops resolving immediately. That is deliberate: who connected this, and when was it removed is a question a security review asks, and a hard delete makes it unanswerable.

purge is the hard delete, and it is what this document's promises are made of. Deleting a tenant must remove their secrets — a soft-deleted credential is still a credential, and "we kept it for the audit trail" is not an answer to "delete my data". purge removes revoked connections too, for the same reason.

The conformance suite holds both, on every adapter: that purge stops at the tenant boundary (a retention job for one customer must not remove another's access) and that it takes revoked rows with it.