fix(indexer): make handleTokensWithdrawn idempotent under replay (#802)#968
fix(indexer): make handleTokensWithdrawn idempotent under replay (#802)#968jadonamite wants to merge 1 commit into
Conversation
61b41ae to
5d1e5dd
Compare
|
heads up: main's ci was broken (the Backend CI and Backend Docker Image CI jobs) until the fixes in #969 and #974 just landed, so the red backend/docker checks on this pr are almost certainly stale, they ran against the broken main. please rebase to re-test against the now-green main: |
5d1e5dd to
7e0cea5
Compare
|
@ogazboiz Rebased onto the latest main — all checks are green now. Ready for your review. |
ogazboiz
left a comment
There was a problem hiding this comment.
good idempotency hardening, moving the dedup findUnique(WITHDRAWN) guard to the top of the transaction and bailing before mutating withdrawnAmount is the right approach. but two things:
- it leaves a duplicate SSE broadcast: on a replayed WITHDRAWN event the guard returns inside the $transaction, but sseService.broadcastToStream runs AFTER the transaction and isn't skipped, so a replay still emits a duplicate stream.withdrawn broadcast.
- more importantly, #980 supersedes this. it makes the same handleTokensWithdrawn idempotent AND removes the legacy soroban-indexer.service.ts, which is the actual root cause of the double-counting (#801) that this pr leaves running, AND fixes the duplicate-SSE issue above via an applied flag.
since they conflict and #980 is the complete fix, i'm going to land #980 and close this as superseded once it's rebased. you both found the same core issue, appreciate the analysis.
…rawnAmount idempotent (LabsCrypt#802) The legacy sorobanIndexerService and SorobanEventWorker were running concurrently, both polling STREAM_CONTRACT_ID. Since handleTokensWithdrawn performed a READ-then-ADD on withdrawnAmount across separate transactions, the same WITHDRAWN event could be applied twice on indexer replay, inflating withdrawnAmount and shrinking the recipient's claimable balance. This fix ensures idempotency via two strategies: 1. Remove the legacy soroban-indexer.service.ts and its bootstrap in index.ts so only SorobanEventWorker polls the contract. 2. Make handleTokensWithdrawn idempotent by checking for the existing StreamEvent (transactionHash, WITHDRAWN) *before* mutating withdrawnAmount. If the event is a duplicate, return early without applying the balance increment or broadcasting a duplicate SSE notification. The StreamEvent.upsert with its unique constraint serves as the final safety net: concurrent replays will fail the insert and roll back the transaction, preventing double-application of the balance. Added regression tests asserting: - Only SorobanEventWorker remains wired into server bootstrap - The same WITHDRAWN event is not double-incremented - Duplicate events do not trigger duplicate SSE broadcasts
0ec6617 to
44abe87
Compare
|
@ogazboiz kindly review |
Overview
This PR resolves a race condition where two indexers (
soroban-indexer.serviceandsoroban-event-worker) were running concurrently, leading to a double-counting ofwithdrawnAmountfor the same events. The legacy service has been removed so that only theSorobanEventWorkerpolls the contract. Additionally, the WITHDRAWN handler has been made idempotent to guarantee that the same event cannot increment the balance more than once.Root Cause
Previously both
sorobanIndexerService(services/soroban-indexer.service.ts) andSorobanEventWorker(workers/soroban-event-worker.ts) polled STREAM_CONTRACT_ID and wrote Stream/StreamEvent rows. Their WITHDRAWN handlers each performed a READ-then-ADD onwithdrawnAmountin separate transactions, allowing the same event to be applied twice on indexer replay → inflated balance.Solution
Two complementary strategies ensure idempotency:
Consolidate to single indexer: Delete the legacy soroban-indexer.service.ts and remove its bootstrap from index.ts. Only SorobanEventWorker now polls the contract.
Idempotent withdrawnAmount mutation: Check for the existing StreamEvent (transactionHash, WITHDRAWN) before mutating withdrawnAmount. If the event is a duplicate, return early without applying the balance increment or broadcasting a duplicate SSE notification.
The StreamEvent.upsert with its unique constraint serves as the final safety net: concurrent replays will fail the insert and roll back the transaction, preventing double-application.
Changes
⚙️ Indexer Consolidation
backend/src/services/soroban-indexer.service.tsbackend/src/index.tssorobanIndexerService.start()call and related graceful shutdown logic.🛡️ Idempotency Improvements
backend/src/workers/soroban-event-worker.tshandleTokensWithdrawnto:StreamEventrecord before applying the balance increment🧪 Tests
backend/tests/soroban-indexer.test.tsbackend/tests/single-indexer.regression.test.tsVerification Results
sorobanIndexerService.start()/stop()fromindex.tsservices/soroban-indexer.service.tswithdrawnAmountis not double-incremented when the same WITHDRAWN event is observedCloses #802