Skip to content

perf(verifier): cache the unfinalized range instead of re-reading it every poll - #1466

Closed
KodeyThomas wants to merge 2 commits into
mainfrom
perf/ccip-evm-unfinalized-range-cache
Closed

KodeyThomas wants to merge 2 commits into
mainfrom
perf/ccip-evm-unfinalized-range-cache

Conversation

@KodeyThomas

@KodeyThomas KodeyThomas commented Sep 18, 2026

Copy link
Copy Markdown
Member

The source reader re-queried logs for the entire finalized..latest window on every poll, because that re-query was also the reorg-detection mechanism.

On chains with fast blocks and slow finality the window is thousands of blocks, so a 2s poll issued ~40 eth_getLogs calls to re-scan blocks whose contents could not have changed.

The EVM reader now caches the hash-linked headers from finalized up to the head. Because every stored header's ParentHash equals the stored header below it, one link check at the head proves the whole range is unchanged and a block hash commits to its entire ancestry, so a reorg at any depth breaks the link from the new head down to the finalized block.

The verifier reads only new blocks while the link holds, and falls back to the full range on a reorg, a tracker error, or a reader without the capability.

Exposed as the optional chainaccess.UnfinalizedRangeTracker so the linkage assumption stays in the EVM package and other chain families keep today's behaviour. SourceReaderUnwrapper is needed alongside it because observedSourceReader embeds the SourceReader interface, which would otherwise hide the capability from the verifier and silently disable the cache.

Additionally adds verifier_source_reader_unfinalized_range_rereads break-even in terms of performance here is a reread rate near 1/5 above which this costs more RPC than it saves, so the rate needs watching per chain on rollout.

@KodeyThomas
KodeyThomas force-pushed the perf/ccip-evm-unfinalized-range-cache branch from 6ef7676 to 4dc1176 Compare September 18, 2026 13:06
…every poll

The source reader re-queried logs for the entire finalized..latest window on
every poll, because that re-query was also the reorg-detection mechanism. On
chains with fast blocks and slow finality the window is thousands of blocks, so
a 2s poll issued ~40 eth_getLogs calls to re-scan blocks whose contents could
not have changed.

The EVM reader now caches the hash-linked headers from finalized up to the head.
Because every stored header's ParentHash equals the stored header below it, one
link check at the head proves the whole range is unchanged - a block hash
commits to its entire ancestry, so a reorg at any depth breaks the link from the
new head down to the anchor, including one that re-extends between two polls.
The verifier reads only new blocks while the link holds, and falls back to the
full range on a reorg, a tracker error, or a reader without the capability.

Exposed as the optional chainaccess.UnfinalizedRangeTracker so the linkage
assumption stays in the EVM package; other chain families keep today's
behaviour. SourceReaderUnwrapper is needed alongside it because
observedSourceReader embeds the SourceReader interface, which would otherwise
hide the capability from the verifier and silently disable the cache.

Adds verifier_source_reader_unfinalized_range_rereads. Break-even is a reread
rate near one poll in five, above which this costs more RPC than it saves, so
the rate needs watching per chain on rollout.
@KodeyThomas
KodeyThomas force-pushed the perf/ccip-evm-unfinalized-range-cache branch from 4dc1176 to 3152a9a Compare September 18, 2026 13:11
@KodeyThomas

Copy link
Copy Markdown
Member Author

recreating with anvil -b 1 --slots-in-an-epoch 900 gives a 1800 block unfinlized window so still way less than abstract saw a drop of 94% from 558/min o 30/min

Comment thread verifier/pkg/sourcereader/service.go Outdated
// Record how far logs have actually been read, which lags the tracked range when a chunk
// fails. The next cycle re-reads from here so a partial read never leaves a gap.
if lastQueried > r.logHighWater.Load() {
r.logHighWater.Store(lastQueried)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a reorg do we need to decrement the logHighWater so we can query again from a lower point that we reached?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah! yeah i think so. nice catch

if stored, ok := t.at(finalized.Number); ok && stored.Hash != finalized.Hash {
t.lggr.Errorw("Finalized block hash disagrees with the cached tail, dropping tail",
"blockNumber", finalized.Number, "storedHash", stored.Hash, "finalizedHash", finalized.Hash)
t.headers = nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be defensive here? Maybe we can return something that indicates the finality violation or return changed in the re-query?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have a seperate component that handles finality violations. would rather keep that seperate and not duplicate logic

The read extent was guarded by `if lastQueried > logHighWater`, making it
monotonic. A reorg that rewinds the head re-mines blocks at heights already
read, and those heights stay below the old mark, so every later poll computed
`from = max(logHighWater+1, checkpoint)` above them and skipped them. Messages
in the re-mined blocks were never discovered until the head passed the old
head, which is what TestE2EReorg asserts against: it reverts to a snapshot and
re-sends messages at the same heights in swapped order.

The guard was a no-op on every non-reorg path - `from` is always
`max(lastRead+1, checkpoint)`, so a successful read always ends above the mark.
It only ever bit after a reorg, where moving backwards is the correct
behaviour. Store the extent unconditionally and rename the field to
lastReadBlock, since it is not a high water mark.
@github-actions

Copy link
Copy Markdown

Code coverage report:

Package main perf/ccip-evm-unfinalized-range-cache Diff
github.com/smartcontractkit/chainlink-ccv/aggregator 50.83% 50.87% +0.04%
github.com/smartcontractkit/chainlink-ccv/bootstrap 72.00% 72.00% +0.00%
github.com/smartcontractkit/chainlink-ccv/cli 50.62% 50.62% +0.00%
github.com/smartcontractkit/chainlink-ccv/cmd 35.65% 35.65% +0.00%
github.com/smartcontractkit/chainlink-ccv/common 48.13% 48.13% +0.00%
github.com/smartcontractkit/chainlink-ccv/executor 42.80% 42.80% +0.00%
github.com/smartcontractkit/chainlink-ccv/indexer 37.23% 37.27% +0.04%
github.com/smartcontractkit/chainlink-ccv/integration 61.45% 61.82% +0.37%
github.com/smartcontractkit/chainlink-ccv/internal 0.00% 0.00% +0.00%
github.com/smartcontractkit/chainlink-ccv/migration 78.70% 78.70% +0.00%
github.com/smartcontractkit/chainlink-ccv/pkg 100.00% 100.00% +0.00%
github.com/smartcontractkit/chainlink-ccv/pricer 0.00% 0.00% +0.00%
github.com/smartcontractkit/chainlink-ccv/protocol 67.04% 67.04% +0.00%
github.com/smartcontractkit/chainlink-ccv/tools 43.61% 43.61% +0.00%
github.com/smartcontractkit/chainlink-ccv/verifier 36.41% 36.53% +0.12%
Total 50.80% 51.20% +0.40%

Files added (in perf/ccip-evm-unfinalized-range-cache):

  • github.com/smartcontractkit/chainlink-ccv/integration/pkg/accessors/evm/chain_tail.go
  • github.com/smartcontractkit/chainlink-ccv/pkg/chainaccess/interfaces.go

@KodeyThomas
KodeyThomas marked this pull request as ready for review September 18, 2026 14:25
@KodeyThomas
KodeyThomas requested review from a team as code owners September 18, 2026 14:25

// Only return early when no progress was made
if lastQueriedBlock.Cmp(fromBlock) == 0 {
if lastQueried == queryFrom {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to reset here as well? If we return here could we still have skipped the lastQueried block

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants