Skip to content

feat(worker): configurable retention for high-volume tables, off by default - #244

Draft
Ahmustufa wants to merge 2 commits into
mainfrom
feature/data-retention
Draft

Ahmustufa wants to merge 2 commits into
mainfrom
feature/data-retention

Conversation

@Ahmustufa

Copy link
Copy Markdown
Contributor

Draft: merge after #242 (branching). Once that merges, this PR is rebased to:

  1. Switch branching's open/click evidence query to tracking_engagement, with a test that rolls up an open and then evaluates a branch on it. Without this, an "opened within 60 days" branch would miss opens older than the retention window and silently send the wrong email.
  2. Resolve an index name collision: both branches create idx_inbox_threads_campaign_contact, with different columns.

This covers the "[Scale] No retention on any high-volume table" Asana task.

What it does

An hourly maintenance:retention job deletes old rows from:

  • deliverability_events
  • inbox threads and messages
  • tracking_events (rolled up into tracking_event_rollups first, so reports survive)
  • sends
  • task_dead_letters

Every recipient-identifying table is OFF unless the operator sets a window. The windows are a Privacy/Legal decision, and PIPEDA or Quebec Law 25 may apply. The docs list the recipient-data tables no window covers.

Safety

  • Migrations don't block writes. Instant DDL, plus one single-statement CREATE INDEX CONCURRENTLY file per index (asserted indisvalid). A static test enforces one statement per file.
  • Batches are scan-bounded. A candidate CTE, guards probed per candidate (a generic-plan EXPLAIN is asserted), a persisted cursor, and a 60s per-batch timeout.
  • One sweeper per run, via an advisory lock, which also removes a cross-replica deadlock.
  • Keep-guards. Nothing is deleted while it's still needed:
    • active enrollments;
    • running or paused campaigns' breaker windows;
    • live inbox threads, scheduled replies and snoozes;
    • CRM deals;
    • the newest complaint per workspace.
  • Reports read the tracking_engagement view (raw plus rolled-up rows), so historical counts don't change. A test fails on any new raw tracking_events reader.

Reviews

  • Performance: 3 high and 4 medium findings, all fixed.
  • Correctness:
    • the critical finding is handled by merge order (above);
    • the major finding (breaker evidence for paused campaigns) is fixed;
    • the minor findings are fixed.

Verification

  • Go: build, vet (plus -tags=integration) and golangci-lint v2.12.2 pass.
  • Unit and integration tests pass, including 22 retention integration tests, migration round trips and the generic-plan check.
  • Not verified: plans at production scale, and mutation tests on the SQL guards.
  • internal/platform/storage fails locally with Windows file locks. It isn't touched here.

🤖 Generated with Claude Code

Ahmustufa and others added 2 commits September 23, 2026 19:37
…efault

A new hourly maintenance:retention job deletes old rows in bounded batches
(5,000 rows, SKIP LOCKED, DB clock, a time budget per run) from
deliverability_events, inbox threads and messages, tracking_events, sends
and task_dead_letters.

Every table that identifies recipients is disabled unless the operator sets
a window (INROAD_RETENTION_*_DAYS). The window is a Privacy/Legal decision,
not a code default. Each table has a minimum below which the worker refuses
to start. Dead letters keep their previous 90-day purge, now configurable.

What "delete" means per table:
- Tracking events are rolled up into tracking_event_rollups in the same
  statement that deletes them. Reports now read the tracking_engagement
  view (raw + rolled-up rows), so historical counts survive.
- Sends: only terminal rows, and never while an enrollment, guardrail
  window, inbox thread, deliverability event or CRM deal still depends on
  them.
- Deliverability events: kept while they feed a running campaign's
  auto-pause window, and each workspace's newest complaint is kept.
- Inbox: only whole conversations, never with a pending reply or an active
  snooze.

The job runs in-process only. It deletes across workspaces, so the remote
client deliberately does not implement it (invariant 73), and the census
test asserts that. The tenancy scanner now treats workspace-scoped views as
tenant tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e review's correctness fixes

Performance audit:
- The migration no longer locks sends for the whole deploy. The first
  migration holds only instant DDL, and each index is its own
  single-statement CREATE INDEX CONCURRENTLY file (asserted indisvalid;
  a static test enforces one statement per file).
- Batches are scan-bounded, not delete-bounded: a candidate CTE over
  (age, id), guards written NOT EXISTS ... OFFSET 0 so they are probed per
  candidate, and a cursor persisted in retention_cursors. A 60s per-batch
  statement_timeout.
- One sweeper per run via a session advisory lock. This also removes the
  cross-replica rollup/purge deadlock.
- New non-partial index on inbox_pending_replies(thread_id), so thread
  deletes don't seq-scan. The redundant idx_tracking_events_send is
  dropped. Autovacuum reloptions, and a first-enable procedure in the docs.

Correctness review:
- The breaker-history guards now cover paused campaigns too, so a
  resumed campaign isn't auto-paused on inflated evidence.
- A test fails if any new query reads raw tracking_events outside an
  explicit allowlist. Readers must use tracking_engagement.
- A tracking hit that races a purge gets the documented 404 (ErrSendGone).
- The docs cover what late replies, bounces and ARF complaints lose once
  their send is deleted, and list the recipient-data tables no window
  covers (a Privacy/Legal call).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant