Closes #679
Implements database-level deduplication and atomic cursor commit for sorobanEventService to prevent double-application of projection updates during crash/restart scenarios.
The previous implementation used an in-memory Set to track processed events by pagingToken, while the cursor was persisted separately in indexer_state. If the service crashed between event processing and cursor update, a restart would re-read already-processed events (the in-memory set is lost), leading to double-applied projections.
- Added
soroban_processed_eventstable with unique constraint onpaging_token - Replaced in-memory
processedTokens Setwith persistent DB storage - Automatic cleanup of events older than 30 days to prevent unbounded growth
- Wrapped event processing + cursor update in single DB transaction
- Ensures cursor is only advanced when event is successfully processed and marked
- Re-processing of same
pagingTokenis now idempotent (skipped on dedup check)
- Added
soroban_event_dlqtable for failed event tracking - Includes
paging_tokenfield for better troubleshooting - Failed events are marked as processed to avoid infinite retry loops
- Creates
soroban_processed_eventstable with unique index onpaging_token - Creates
soroban_event_dlqtable for failed event tracking - Adds indexes for efficient cleanup and lookup queries
- Removed in-memory
processedTokens SetandMAX_DEDUP_SET_SIZE - Added
isEventProcessed()- checks DB for already-processed events - Added
markEventProcessed()- inserts processed event record - Added
cleanupOldProcessedEvents()- removes events older than 30 days - Updated
pollEvents()- uses DB transaction for atomic processing - Updated
saveCursor()- accepts optional client for transaction - Updated
writeToDLQ()- includespaging_tokenfield - Updated
rescan()- clears processed events table for full rescan - Updated
getStatus()- removedprocessedTokenCount(no longer in-memory)
- ✅ Processes new event and marks as processed in DB
- ✅ Skips already-processed event (redelivery idempotency)
- ✅ Atomic cursor update with event processing
- ✅ Writes failed event to DLQ and still marks as processed
- ✅ Processes multiple events in sequence
- ✅ Handles transaction rollback on error
- ✅ Extracts event types correctly
- ✅ Cleanup runs probabilistically
- Added entry under
### Fixedsection
npm test -- sorobanEventService.test.js- Start the service and process events
- Simulate crash (kill process)
- Restart service
- Verify events are not double-processed (check logs for "already processed - skipping")
- Check
soroban_processed_eventstable for deduplication records
npm run db:migrate- DB-level unique index on event's pagingToken
- Event insert + cursor update wrapped in one transaction
- Redelivery idempotency test added
- Re-processing a pagingToken does not double-apply projections
- Standard backend CI passes
- CHANGELOG entry added
- Performance: Minimal overhead - one additional DB insert per event (within same transaction)
- Storage:
soroban_processed_eventstable grows with event volume but auto-cleans after 30 days - Reliability: Eliminates double-application bug, making event ingestion truly idempotent
If issues arise:
npm run db:rollback # Reverts migration 028
git revert d6ca0f6 # Reverts code changes- Closes #679 - Backend:
sorobanEventServicein-memory dedup set + separate cursor commit creates a double-process window
Labels: GrantFox OSS, Official Campaign, area/backend, type/bug, priority/medium
Tested on: Development environment with PostgreSQL 15
Contributors: @senmalong