Skip to content

fix(storage): bound the transaction cleanup probes to the batch range - #1583

Merged
zizou0x merged 2 commits into
mainfrom
zz/range-bounded-transaction-cleanup
Oct 12, 2026
Merged

zizou0x merged 2 commits into
mainfrom
zz/range-bounded-transaction-cleanup

Conversation

@zizou0x

@zizou0x zizou0x commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

What

cleanup_orphaned_transactions keeps its structure; each of its nine reference checks gains the batch's id range:

AND NOT EXISTS (SELECT 1 FROM component_balance cb WHERE cb.modify_tx = t.id
                 AND cb.modify_tx > v_cursor AND cb.modify_tx <= v_batch_end)

The added predicate is implied by the equality, so the same rows qualify. The migration redefines the procedure with the body from 2026-07-03_incremental_transaction_cleanup plus these nine lines; down.sql restores the original body.

How to review

PL/pgSQL has no partial edit, so the migration must carry the whole procedure body (CREATE OR REPLACE) and git shows the file as new. The procedure body is a verbatim copy of the one in 2026-07-03_incremental_transaction_cleanup/up.sql except for the nine NOT EXISTS lines below; down.sql is the verbatim original. To confirm nothing else differs:

body() { awk '/^CREATE OR REPLACE PROCEDURE cleanup_orphaned_transactions/,/^\$\$;/' "$1"; }
diff <(body crates/tycho-storage/migrations/2026-07-03_incremental_transaction_cleanup/up.sql) \
     <(body crates/tycho-storage/migrations/2026-10-11_range_bounded_transaction_cleanup/up.sql)

The full diff between the two bodies:

-              AND NOT EXISTS (SELECT 1 FROM contract_code cc WHERE cc.modify_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM protocol_component pc WHERE pc.creation_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM protocol_component pc WHERE pc.deletion_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM account a WHERE a.creation_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM account a WHERE a.deletion_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM account_balance ab WHERE ab.modify_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM component_balance cb WHERE cb.modify_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM protocol_state ps WHERE ps.modify_tx = t.id)
-              AND NOT EXISTS (SELECT 1 FROM contract_storage cs WHERE cs.modify_tx = t.id);
+              AND NOT EXISTS (SELECT 1 FROM contract_code cc WHERE cc.modify_tx = t.id
+                               AND cc.modify_tx > v_cursor AND cc.modify_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM protocol_component pc WHERE pc.creation_tx = t.id
+                               AND pc.creation_tx > v_cursor AND pc.creation_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM protocol_component pc WHERE pc.deletion_tx = t.id
+                               AND pc.deletion_tx > v_cursor AND pc.deletion_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM account a WHERE a.creation_tx = t.id
+                               AND a.creation_tx > v_cursor AND a.creation_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM account a WHERE a.deletion_tx = t.id
+                               AND a.deletion_tx > v_cursor AND a.deletion_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM account_balance ab WHERE ab.modify_tx = t.id
+                               AND ab.modify_tx > v_cursor AND ab.modify_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM component_balance cb WHERE cb.modify_tx = t.id
+                               AND cb.modify_tx > v_cursor AND cb.modify_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM protocol_state ps WHERE ps.modify_tx = t.id
+                               AND ps.modify_tx > v_cursor AND ps.modify_tx <= v_batch_end)
+              AND NOT EXISTS (SELECT 1 FROM contract_storage cs WHERE cs.modify_tx = t.id
+                               AND cs.modify_tx > v_cursor AND cs.modify_tx <= v_batch_end);

Why

component_balance, protocol_state and contract_storage are partitioned by valid_to, not by modify_tx, so the correlated probe x.modify_tx = t.id costs one index lookup in every partition for every transaction the sweep considers: 117 partitions across the three tables on dev-base, 186 on dev-bsc, 50,000 rows per batch, for the whole 45-minute budget, twice a day. That is the 02:00 and 14:00 load seen on base and bsc (4,000 to 7,000 read IOPS on base, 75 to 100% CPU on bsc), and the sweep never catches up on those chains, while on ethereum and fynd-prod-base, where it is caught up, it finishes in minutes.

With the range in the subquery the planner runs one short range scan per partition per batch and anti-joins the results.

Measured

One 50,000-id batch at each DB's current cursor, read-only SELECT count(*) with the same predicates, 2026-10-11:

DB Orphans found Current form Range-bounded form
dev-base 49,975 both 3.17 s, 11.6M buffer hits 54 ms, 476 buffers
dev-bsc 43,681 both 11.07 s, 14.2M buffer hits 56 ms, 676 buffers

Those are warm-cache numbers; the real runs are cold, which is where the IOPS come from.

Test

test_cleanup_orphaned_transactions_serial_db (references survive, orphans are deleted) passes against the new body. Migration applied, reverted and re-applied locally; pg_proc shows the ranged body after up and the original after down.

Context: this replaces the index-rebuild approach in #1580, which measured no change in the cleanup's cost on 2026-10-10 because the rebuilt default-partition index is one of ~40 per table being probed.

🤖 Generated with Claude Code

The reference checks in cleanup_orphaned_transactions ran a correlated
NOT EXISTS per transaction against the partitioned tables, whose
partition key is not modify_tx, so every row cost one index probe per
partition: 117 to 186 partitions across the three tables, 50k rows per
batch, for the full 45-minute budget twice a day. That is the disk-read
and CPU storm seen at 02:00 and 14:00 on base and bsc.

Add the batch's id range to each subquery so the planner does one short
range scan per partition per batch and an anti-join. The predicate is
implied by the equality, so the same rows qualify. One 50k batch on dev:
base 3.17 s to 54 ms, bsc 11.07 s to 56 ms, identical orphan counts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@zizou0x zizou0x changed the title perf(storage): bound the transaction cleanup probes to the batch range fix(storage): bound the transaction cleanup probes to the batch range Oct 11, 2026
@zizou0x
zizou0x enabled auto-merge October 12, 2026 15:53
@zizou0x
zizou0x merged commit e4b6ac6 into main Oct 12, 2026
29 checks passed
@zizou0x
zizou0x deleted the zz/range-bounded-transaction-cleanup branch October 12, 2026 16:00
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 12, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants