From 8118dbc813649fc246dffbc669e64841531873de Mon Sep 17 00:00:00 2001 From: Julien Lucca Date: Sun, 18 Oct 2026 14:30:20 +0200 Subject: [PATCH] fix(indexer): apply the rest of a block delivered in two parts GetActionsReader can hand one block over twice: when a 100-action batch ends inside the block, or when one contract's get_actions saw the block and another's didn't yet. Both parts carry the same synthetic hash, and handleBlock skipped any block whose number and hash matched the last one processed, so the second part was dropped for good (the reader had already advanced its seq cursor and seen-set). Prod hit it on 2026-10-14: block 437155575's cambiatus.tk::transfer was indexed, its cambiatus.cm::transfersale was skipped, and that order is missing from Postgres. Skip a repeated block only when it carries no actions. Applying the second part is safe: the reader never re-sends an action it has seen, and every updater is ledgered on global_action_seq. Co-Authored-By: Claude Opus 5.5 --- src/GetActionsHandler.js | 10 ++++++-- test/split_block.test.js | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 test/split_block.test.js diff --git a/src/GetActionsHandler.js b/src/GetActionsHandler.js index db0891a..fb74837 100644 --- a/src/GetActionsHandler.js +++ b/src/GetActionsHandler.js @@ -12,10 +12,16 @@ class GetActionsHandler extends MassiveActionHandler { await this.refreshIndexState() } - // Skip if we've already processed this exact block + // Skip an empty re-delivery of the block we just processed. One that carries actions + // is the rest of that block: GetActionsReader splits a block when a 100-action batch + // ends inside it, or when one contract's fetch saw the block and another's didn't yet. + // Both parts share the synthetic hash, so skipping on the hash alone dropped the second + // part for good (prod, block 437155575: a transfersale). Applying it is safe: the reader + // never re-sends a seen action, and every updater is ledgered on global_action_seq. if ( blockInfo.blockNumber === this.lastProcessedBlockNumber && - blockInfo.blockHash === this.lastProcessedBlockHash + blockInfo.blockHash === this.lastProcessedBlockHash && + (!block.actions || block.actions.length === 0) ) { return [false, 0] } diff --git a/test/split_block.test.js b/test/split_block.test.js new file mode 100644 index 0000000..ad81623 --- /dev/null +++ b/test/split_block.test.js @@ -0,0 +1,52 @@ +/* eslint-env mocha */ +// GetActionsReader can hand over one block in two parts: a 100-action batch that ends +// mid-block, or a block that one contract's fetch saw and another's didn't yet (prod lost +// a cambiatus.cm::transfersale this way on 2026-10-14, block 437155575). Both parts carry +// the same synthetic hash, so the handler must still apply the second part. +// No database: handleWithState is stubbed to record which blocks reach the updaters. +const assert = require('assert') +const { GetActionsHandler } = require('../src/GetActionsHandler') + +function block (blockNumber, types) { + return { + blockInfo: { + blockNumber, + blockHash: `synthetic-${blockNumber}`, + previousBlockHash: `synthetic-${blockNumber - 1}`, + timestamp: new Date('2026-10-14T11:58:02Z') + }, + actions: types.map(type => ({ type, payload: {} })) + } +} + +function handler () { + const h = new GetActionsHandler([], [], {}, 'public') + h.handled = [] + // Already past the first block, so refreshIndexState and the resume seek don't run. + h.lastProcessedBlockNumber = 100 + h.lastProcessedBlockHash = 'synthetic-100' + h.handleWithState = async () => { + h.handled.push(h.pending.actions.map(a => a.type)) + h.lastProcessedBlockNumber = h.pending.blockInfo.blockNumber + h.lastProcessedBlockHash = h.pending.blockInfo.blockHash + } + h.deliver = b => { h.pending = b; return h.handleBlock(b, false, false) } + return h +} + +describe('GetActionsHandler: a block delivered in two parts', () => { + it('applies both parts', async () => { + const h = handler() + await h.deliver(block(101, ['cambiatus.tk::transfer'])) + await h.deliver(block(101, ['cambiatus.cm::transfersale'])) + assert.deepStrictEqual(h.handled, [['cambiatus.tk::transfer'], ['cambiatus.cm::transfersale']]) + }) + + it('still skips an empty re-delivery of the last block', async () => { + const h = handler() + await h.deliver(block(101, ['cambiatus.cm::claimaction'])) + const result = await h.deliver(block(101, [])) + assert.deepStrictEqual(result, [false, 0]) + assert.deepStrictEqual(h.handled, [['cambiatus.cm::claimaction']]) + }) +})