Skip to content

perf(outbox): resume each retention batch where the previous run's batch ended - #9446

Merged
waleedlatif1 merged 2 commits into
stagingfrom
perf/outbox-prune-resume-cursor
Oct 29, 2026
Merged

waleedlatif1 merged 2 commits into
stagingfrom
perf/outbox-prune-resume-cursor

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • The completed-outbox prune deletes one oldest-first batch per type per run, and every batch started its index scan at the oldest row of its type. Rows deleted by earlier batches stay in outbox_event_type_created_idx until vacuum, so while a backlog drains each batch first walks everything deleted since the last vacuum (about 0.6s before the first live row on a read replica; a batch can reach the 10s statement timeout)
  • Each batch now resumes after the (created_at, id) of the last row the previous run's batch deleted, the same resume shape as the idempotency sweep. Batches order by (created_at, id) so ties split exactly; the cursor keeps PostgreSQL's microsecond text form. The row comparison plans as a created_at >= index bound with the full comparison as a filter (plain EXPLAIN checked); resuming, the same batch took about 20ms
  • The prune runs once per processor run, so the cursor outlives the run as a Redis hint (one MGET before the batches, one pipeline after). The cursor clears when a batch comes up short of the cutoff, and a pass older than an hour starts over from the oldest row, so a row the pass went by before it completed, or while an overlapping run held its lock, is still pruned by the next pass
  • Without Redis, or with an unreadable cursor, a batch starts from the oldest row exactly as before. Batch size, statement timeout, cadence and eligibility (type list, completed, retention window) are unchanged

Type of Change

  • Performance improvement

Testing

  • lib/core/outbox/retention.integration.ts against real PostgreSQL and Redis: a row completed behind the cursor between runs is left for the next pass and then pruned (fails on the previous code, which takes it at once); one creation time split across batches with pending rows and non-prunable types mixed in deletes exactly the eligible rows; a pass over an hour old and an unreadable cursor both restart from the oldest row. Reverting the short-batch reset, the id tiebreak, or the pass age limit each turns its case red
  • Existing retention cases still pass without Redis; lib/core/outbox unit tests
  • bun run lint, bun run type-check (apps/sim), bun run check:audits (61 audits)

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing (new tests pass the test-audit authoring gate)
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…tch ended

The completed-event prune deletes one oldest-first batch per type per run.
Every batch started its scan of outbox_event_type_created_idx at the oldest
row of its type, and the rows earlier batches deleted stay in that index until
vacuum removes them, so while a large backlog drains each batch first walks
everything deleted since the last vacuum. That walk grows with each run: on a
read replica during a large drain it took about 0.6s before the first live
row, and a batch can reach the 10s statement timeout.

Each batch now resumes after the (created_at, id) of the last row the previous
run's batch deleted, the same shape as the idempotency sweep's resume. Batches
are ordered by (created_at, id) so ties on created_at split exactly, the cursor
keeps PostgreSQL's microsecond text form, and the row comparison becomes a
created_at >= bound on the index, with the full comparison as a filter.
Resuming from a cursor, the same batch took about 20ms.

The prune runs once per processor run, so the cursor has to outlive the run;
it is kept in Redis as a hint, one MGET before the batches and one pipeline
after. A row the pass went by before it completed, or while an overlapping run
held its lock, is covered by the next pass: the cursor is cleared when a batch
comes up short of the cutoff, and a pass older than an hour starts over from
the oldest row. Without Redis, or with an unreadable cursor, a batch starts
from the oldest row as before. Batch size, timeout, cadence and which rows are
eligible are unchanged.

Autopsy: the retention suite asserted which rows each run deleted, which a scan
from the oldest row gets right, so nothing observed where a batch started. The
new Redis cases complete a row behind the cursor between runs: resuming leaves
it for the next pass and the old scan takes it at once, so the first case fails
on the previous code. The others split one creation time across batches with
pending rows mixed in, and restart a pass that is over an hour old or whose
cursor is unreadable.
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs Skipped Skipped Oct 29, 2026 6:31pm UTC

Request Review

@greptile-apps

greptile-apps Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the previous cursor issue is fixed.

Summary

Completed outbox cleanup now resumes each event type after the previous batch’s last (created_at, id) pair.

  • Outbox pruning resumes each event type after its last full batch.

The previous finding is fully addressed. No new actionable issues were found.

Reviews (2) · Last reviewed commit: "fix(outbox): start a pass from the oldes..." · Reviewed by Greptile

Comment thread apps/sim/lib/core/outbox/retention.ts Resolved

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread apps/sim/lib/core/outbox/retention.ts Resolved
…names no real time

A cursor whose JSON parsed but whose creation time was not a calendar time
reached the batch's ::timestamp cast and failed the run, which stopped every
later type's prune until the cursor expired. The cursor's creation time is now
checked as PostgreSQL's ISO text with a calendar round trip, so such a cursor
is dropped like an unreadable one and the pass starts from the oldest row.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Turn on auto-fix | Re-trigger cubic

@waleedlatif1
waleedlatif1 merged commit 94b33d2 into staging Oct 29, 2026
68 checks passed
@waleedlatif1
waleedlatif1 deleted the perf/outbox-prune-resume-cursor branch October 29, 2026 18:43

This branch was previously deployed

1 inactive deployment
Preview — d257370e Deployed Oct 29, 2026 by vercel[bot]
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