Read blocks ahead so gateway latency leaves the critical path - #27
Open
vladbucur1 wants to merge 3 commits into
Open
Read blocks ahead so gateway latency leaves the critical path#27vladbucur1 wants to merge 3 commits into
vladbucur1 wants to merge 3 commits into
Conversation
At 600ms block time the chain produces 1.67 blocks/s/shard. The processor read one block at a time and awaited each read inline, so the gateway sat idle for the whole duration of the consumer callback and every nonce paid a full round-trip. Measured against mainnet with a 100ms onTransactionsReceived that came to 1.60 nonces/s/shard - below the production rate, so a backlog once formed could never drain. Add a bounded read-ahead cache per shard. While nonce N is handed to the consumer, the reads for N+1..N+maxPrefetch are already in flight, so take() almost always resolves an already-settled promise. Delivery order is unchanged: still one nonce per shard per pass, in order, committed only after the callback returns. Only the reads moved off the critical path, which keeps the cross-shard SCR counters in crossShardDictionary seeing blocks in exactly the order they did before. The window never extends past the tip observed at the start of the pass, so it cannot request a block the network has not produced yet, and it collapses to a single read when already caught up. Windows are dropped on a network reset and pruned after a maxLookBehind jump. A read failure now resolves to undefined rather than rejecting, since the catch is attached inside prime(). That is the value the loop already treats as 'block not available', so one flaky shard leaves the nonce uncommitted for a later retry instead of aborting the whole run. Previously a single failing shard threw out of start() and stopped every other shard: 1/30 nonces delivered, now 30/30. Also give HttpService shared keep-alive agents. start() is normally driven by a sub-second cron and rebuilds its HttpService each tick, so the agents are module-level - per-instance agents would discard the socket pool every tick and pay a fresh TLS handshake per request (~165ms vs ~52ms). Node 19+ already defaults its global agent to keepAlive; this keeps the behaviour on older runtimes and caps the pool during a read-ahead burst. Measured on mainnet, catching up 25 nonces/shard across 4 shards: no-op callback 4.16 -> 20.51 nonces/s/shard 100ms callback 1.60 -> 2.37 nonces/s/shard maxPrefetch defaults to 10; set it to 1 to restore the previous one-block-at- a-time behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2BfVKhgjNR2FdJR6V6GXQ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
At 600ms block time the chain produces 1.67 blocks/s/shard. The processor reads one block at a time and awaits each read inline (
transaction.processor.ts:106onmain), so the gateway sits idle for the entire duration of the consumer callback and every nonce pays a full round-trip.Measured against mainnet gateway, catching up 25 nonces/shard across 4 shards:
mainmainWith a 100ms
onTransactionsReceived,mainruns below the production rate. It is not slow, it is structurally under water — a backlog once formed grows forever. That is the "behind a few thousand blocks and keeps getting back" symptom.Change
A bounded read-ahead cache per shard (
src/utils/block-prefetcher.ts). While nonce N is being handed to the consumer, the reads forN+1..N+maxPrefetchare already in flight, sotake()almost always resolves an already-settled promise.Delivery order is unchanged. Still one nonce per shard per pass, in order,
setLastProcessedNonceonly after the callback returns. Only the reads moved off the critical path. This is deliberate: letting shards drift apart would breakgetFinalizedCrossShardScrTransactionspass 2 (transaction.processor.ts:307), which silently drops a decrement whose increment has not landed yet — the counter would never reach 0 and the transaction would sit incrossShardDictionaryuntil the 600s prune, never notified. Keeping delivery in lockstep means the SCR counters see blocks in exactly the order they do today.Safety properties:
maxLookBehindjump.shards × maxPrefetchblocks (~2MB at the default).Bonus: one flaky shard no longer stops every shard
The
.catchis attached synchronously insideprime(), so a failed read resolves toundefined— the value the loop already treats as "block not available". The nonce stays uncommitted for a later retry instead of throwing out ofstart().Test: shard 1 always fails, 3 healthy shards 10 nonces behind.
Also: shared keep-alive agents in HttpService
start()is normally driven by a sub-second cron and rebuilds itsHttpServiceeach tick, so the agents are module-level. Per-instance agents would discard the socket pool every tick and pay a fresh TLS handshake per request (~165ms vs ~52ms measured).Being straight about the value: Node 19+ already defaults
globalAgent.keepAliveto true, so this is worth 0x on modern Node and ~3.1x on Node ≤18. It is a guarantee for SDK consumers on older runtimes plus a socket-pool cap during a read-ahead burst — it is not where the speedup above comes from. All of that is the read-ahead window.API
One new option, backwards compatible:
Applied to both
ShardblockandHyperblockmodes. Hyperblock gains the most, being a single serial stream.Verification
tsc --noEmit,npm run lintandnpm run buildall clean. The repo has no test suite (npm testis stillexit 1), so the numbers above come from harness scripts driving the real processor againsthttps://gateway.multiversx.comand comparing against amainbuild. Nonce and callback counts matched exactly across builds (120 = 30 × 4), so nothing is skipped or double-delivered. Happy to land the harness as a benchmark script if useful.Known remaining ceiling — not addressed here
2.37is close to a hard ceiling of2.5. The callback is awaited one shard at a time insidefor (const shardId of this.shardIds), so 4 shards × 100ms is a 400ms floor per pass no matter how warm the cache is. Read-ahead cannot touch that.Breaking it needs callback delivery parallel across shards, turning
sum(400ms)intomax(100ms)— roughly 2.5 → 10 nonces/s/shard. That should be auto-gated onwaitForFinalizedCrossShardSmartContractResultsbeing falsy, for the ordering reason above. Left for a follow-up.Separately:
package.jsonversion is untouched — a minor bump would be appropriate for the new option.Supersedes #26.
🤖 Generated with Claude Code
https://claude.ai/code/session_01S2BfVKhgjNR2FdJR6V6GXQ