Skip to content

Commit 24bfe75

Browse files
fix: stop poison-pill batches from freezing indexer cursor
1 parent 6db14d7 commit 24bfe75

2 files changed

Lines changed: 16 additions & 25 deletions

File tree

backend/src/workers/soroban-event-worker.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ export class SorobanEventWorker {
342342

343343
let lastCursor: string | null = state.lastCursor;
344344
let lastLedger: number = state.lastLedger;
345-
let hasError = false;
345+
let sawSuccess = false;
346346

347347
// Sort events so that 'stream_created' events are processed first in the batch.
348348
// This ensures that subsequent events (like 'fee_collected') that depend on
@@ -363,13 +363,12 @@ export class SorobanEventWorker {
363363
await this.processEvent(event);
364364
this.eventsProcessed += 1;
365365
this.recordOutcome(true);
366-
if (!hasError) {
367-
// Use the event ID as the cursor if pagingToken is not available
368-
lastCursor = event.id;
369-
lastLedger = event.ledger;
370-
}
366+
sawSuccess = true;
367+
// Advance the cursor to the most recent event that was successfully processed.
368+
// This keeps a single malformed event from pinning the entire batch forever.
369+
lastCursor = event.id;
370+
lastLedger = event.ledger;
371371
} catch (err) {
372-
hasError = true;
373372
this.eventsFailed += 1;
374373
this.lastErrorAt = new Date();
375374
this.recordOutcome(false);
@@ -381,10 +380,11 @@ export class SorobanEventWorker {
381380
}
382381
}
383382

384-
// Use the response's final cursor if provided and no error occurred, otherwise the last valid event's ID
385-
const finalCursor = hasError
386-
? lastCursor
387-
: ((response as any).latestCursor || lastCursor);
383+
// If we successfully processed any events in the batch, advance to the last
384+
// successful event so a single poison-pill failure cannot freeze the cursor.
385+
const finalCursor = sawSuccess
386+
? ((response as any).latestCursor || lastCursor)
387+
: lastCursor;
388388

389389
await prisma.indexerState.upsert({
390390
where: { id: INDEXER_STATE_ID },

backend/tests/soroban-event-worker.test.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,7 @@ describe('SorobanEventWorker', () => {
687687
expect(typeof capturedEventUpsert?.create?.streamId).toBe('bigint');
688688
});
689689

690-
it('cursor_does_not_advance_past_failed_event_in_mixed_batch', async () => {
690+
it('cursor_advances_past_valid_events_after_an_earlier_failed_event_in_same_batch', async () => {
691691
// Setup initial state: lastCursor is 'cursor-initial'
692692
(prisma.indexerState.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
693693
id: 'singleton',
@@ -721,7 +721,6 @@ describe('SorobanEventWorker', () => {
721721
} as any,
722722
};
723723

724-
// Event 2: Valid admin_transferred event
725724
const event2: rpc.Api.EventResponse = {
726725
id: 'cursor-event-2',
727726
type: 'contract',
@@ -743,7 +742,6 @@ describe('SorobanEventWorker', () => {
743742
} as any,
744743
};
745744

746-
// Event 3: Valid admin_transferred event
747745
const event3: rpc.Api.EventResponse = {
748746
id: 'cursor-event-3',
749747
type: 'contract',
@@ -765,12 +763,10 @@ describe('SorobanEventWorker', () => {
765763
} as any,
766764
};
767765

768-
// Mock getEvents on worker.server
769766
vi.spyOn((worker as any).server, 'getEvents').mockResolvedValue({
770767
events: [event1, event2, event3],
771768
});
772769

773-
// Track upserted stream events
774770
const upsertedStreamEvents: any[] = [];
775771
const mockTx = {
776772
user: { upsert: vi.fn().mockResolvedValue({}) },
@@ -786,31 +782,26 @@ describe('SorobanEventWorker', () => {
786782

787783
(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementation((cb) => cb(mockTx));
788784

789-
// Run fetchAndProcessEvents
790785
await (worker as any).fetchAndProcessEvents();
791786

792-
// Assert successful later events (event2 and event3) were written exactly once each
793787
const event1Writes = upsertedStreamEvents.filter(
794-
(e) => e.create?.transactionHash === 'tx-failed-1'
788+
(e) => e.create?.transactionHash === 'tx-failed-1',
795789
);
796790
const event2Writes = upsertedStreamEvents.filter(
797-
(e) => e.create?.transactionHash === 'tx-success-2'
791+
(e) => e.create?.transactionHash === 'tx-success-2',
798792
);
799793
const event3Writes = upsertedStreamEvents.filter(
800-
(e) => e.create?.transactionHash === 'tx-success-3'
794+
(e) => e.create?.transactionHash === 'tx-success-3',
801795
);
802796

803797
expect(event1Writes.length).toBe(0);
804798
expect(event2Writes.length).toBe(1);
805799
expect(event3Writes.length).toBe(1);
806800

807-
// Assert: persisted IndexerState.lastCursor is NOT advanced past the failed event's position
808-
// (i.e. it must not be set to 'cursor-event-2' or 'cursor-event-3' after a failure in event 1)
809801
const indexerUpsertCalls = (prisma.indexerState.upsert as ReturnType<typeof vi.fn>).mock.calls;
810802
const lastSaveCall = indexerUpsertCalls[indexerUpsertCalls.length - 1]![0];
811803

812-
expect(lastSaveCall.update.lastCursor).not.toBe('cursor-event-2');
813-
expect(lastSaveCall.update.lastCursor).not.toBe('cursor-event-3');
804+
expect(lastSaveCall.update.lastCursor).toBe('cursor-event-3');
814805
});
815806
});
816807

0 commit comments

Comments
 (0)