fix(utxo): wrap mempool_clear_expired in BEGIN IMMEDIATE - #8181
Conversation
The clear_expired SELECT and subsequent DELETEs ran without a write lock, so a concurrent mempool_add could interleave between them and leave orphan utxo_mempool_inputs rows (persistent UTXO locks / DoS). Now matches the pattern already used in mempool_add() and mempool_remove() — acquire BEGIN IMMEDIATE before the SELECT, commit after the DELETEs, rollback on error. Also updated the B2 PoC test to verify the fix instead of the bug, and added a regression test for atomic input+tx removal. Fixes Scottcjn#8176
|
PR #8181 opened. mempool_clear_expired() now acquires BEGIN IMMEDIATE before the expiry scan + deletes, matching mempool_add/mempool_remove. 100 tests pass. |
|
Welcome to RustChain! Thanks for your first pull request. Before we review, please make sure:
Bounty tiers: Micro (1-10 RTC) | Standard (20-50) | Major (75-100) | Critical (100-150) A maintainer will review your PR soon. Thanks for contributing! |
FlintLeng
left a comment
There was a problem hiding this comment.
PR Review: UTXO Mempool Race Condition — BEGIN IMMEDIATE
Reviewed on: 2026-08-04
Summary
Fixes a SQLite concurrency race in mempool_clear_expired(). The original code SELECTed expired tx IDs without a write lock, then looped through DELETEs — a concurrent mempool_add() or apply_transaction() could interleave between the SELECT and DELETEs, leaving orphan utxo_mempool_inputs rows that permanently hold UTXO locks.
Root Cause Analysis ✅
The comment correctly identifies the pattern: BEGIN IMMEDIATE is already used by mempool_remove() and mempool_add() in this codebase. The absence of it in mempool_clear_expired() was an oversight, not a deliberate design choice. The diff shows the fix is consistent with the established pattern across the three mempool mutation functions.
Code Quality ✅
Structure is clean:
conn.execute("BEGIN IMMEDIATE")
try:
expired = conn.execute(SELECT ...)
for row in expired:
conn.execute(DELETE inputs)
conn.execute(DELETE main)
conn.commit()
except:
conn.execute("ROLLBACK")
raise- Rollback in
exceptblock prevents half-committed state on any failure — correct. - The
"no such table"path (fresh DB) returns 0 after rollback — graceful degradation. - The original
else: count = 0 ... return countwas structurally confusing (return inside a loop with a deferred return after it). Flattening it into sequential code is clearer.
The nested else on try/except/else was particularly confusing in the original. The else clause only ran if no exception occurred, making the flow: try (SELECT) → except (no table) → else (loop+commit). The new code removes this ambiguity entirely.
Test Coverage ✅
- Regression test
TestClearExpiredAtomicitydirectly verifies bothutxo_mempoolandutxo_mempool_inputsrows are deleted atomically — tests the actual failure mode (orphan inputs), not just the happy path. test_clear_expired_handles_missing_tabletests the graceful degradation path.- The PoC tests (
B1/B2) flipping fromassertFalse→assertTrueis an honest way to document the fix — acceptable in a regression test suite.
Minor Notes
-
import time as _timeinside the test method shadows the module-leveltime— minor style nits, harmless in a test. -
conn.execute("ROLLBACK")in theexceptblock can itself raise (e.g., if the transaction already committed or if the connection is broken). Wrapped in another nestedtry/except— correct.
Wallet: RTC019e78d600fb3131c29d7ba80aba8fe644be426e
✅ LGTM — clean, correct race condition fix with solid regression coverage.
Summary
mempool_clear_expired()performed a SELECT to find expired transactions, then looped through doing two DELETEs per row without holding a write lock. A concurrentmempool_add()orapply_transaction()could interleave between the SELECT and the DELETEs, producing orphanutxo_mempool_inputsrows or inconsistent mempool state.This is the same bug class that was already fixed in
mempool_remove()(BUG-1), butmempool_clear_expired()was left out.Fix
Wrap the SELECT + DELETEs in
BEGIN IMMEDIATE, matching the pattern already established inmempool_add()(L1043) andmempool_remove()(L1232):conn.commit()after all deletes completeROLLBACKin the except block for error pathsno such tableearly-return now doesROLLBACKbefore returning 0Testing
Updated
test_utxo_mempool_concurrent_stress_poc.py(the existing B2 PoC) so its assertions verify the fix rather than the bug, and added two regression tests:test_clear_expired_removes_inputs_and_tx_atomically— verifies both the tx row and its input rows are gone after expiry clear (no orphans)test_clear_expired_handles_missing_table— verifies graceful 0-return on a fresh DB without tablesFixes #8176