Skip to content

feat(node): hold off RPC queries anchored on just-unseen blocks - #25203

Merged
alexghr merged 9 commits into
merge-train/spartan-v5from
spl/a-1688-hold-off-unseen-anchor-v5
Aug 18, 2026
Merged

feat(node): hold off RPC queries anchored on just-unseen blocks#25203
alexghr merged 9 commits into
merge-train/spartan-v5from
spl/a-1688-hold-off-unseen-anchor-v5

Conversation

@spalladino

@spalladino spalladino commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Context

When RPC nodes sit behind a load balancer, a client can sync to block N+1 through one node and then issue follow-up queries — anchored on that block — against a different node that has only seen block N. Today the lagging node fails those requests immediately (block hash not found... possibly a reorg has occurred), even though it would have the block within a second or two. The client then aborts or retries a whole flow (tx construction, note sync) over a transient skew of a single block.

Approach

When a query references a block the node has not seen, the node now waits a bounded time for it to arrive instead of failing straight away. Outcomes are unchanged, only delayed: on a miss callers still throw or return undefined exactly as before, with the same errors.

  • Query by block number N: waits only when N == proposed tip + 1, up to RPC_UNSEEN_BLOCK_BY_NUMBER_WAIT_MS (defaults to twice the block duration, i.e. 6s at the 3s default). A number further ahead, or at/below the tip but missing (pruned or reorged), still fails fast.
  • Query by block hash or archive root: the node cannot tell "one block ahead" from "reorged away", so a miss waits up to the shorter RPC_UNSEEN_BLOCK_BY_HASH_WAIT_MS (default 3000ms).
  • Query by tag (latest, proven, ...): never waits, since tags resolve against the current tip by definition.
  • Both waits are env-configurable and setting either to 0 restores the previous fail-fast behavior.
  • A cap of 100 simultaneously held requests bounds resource use; beyond it, misses fail fast as before. Polling is one point read per held request every 200ms.

A single UnseenBlockHoldOff instance is shared by every block-anchored read, so the cap applies across all of them: the world-state witness queries (findLeavesIndexes, membership witnesses, getPublicDataWitness, getPublicStorageAt), getBlock / getBlockData (and getContract through it), and the referenceBlock anchor of getPrivateLogsByTags / getPublicLogsByTags, which fails the same way during PXE note sync. The log store's own in-transaction anchor check stays authoritative — the hold-off only gives the block a chance to land first. The hold-off lives entirely in the RPC-serving layer; internal consumers of the block source (sequencer, validator, archiver sync loops) are untouched.

getWorldState retries a resolution failure three times, so it holds off only on the first attempt — otherwise a 6s budget would become 18s for the client.

There is no wire or schema change, so this is fully backwards compatible in both directions and old clients simply benefit.

Notes

  • Part of A-1688. Part 2 (on the v6 line) extends BlockParameter so a client can send both the anchor number and hash, which lets the server tell "one ahead" from "reorged" precisely and removes the blind 3s hash wait; that PR will close the issue.
  • Accepted trade-off: a request with a genuinely bad hash (a real reorg) takes up to 3s longer to fail. Bounded, configurable, and only on the miss path.
  • No changelog entry here: the operator changelog is organized per released version and there is no unreleased-v5 section to append to, so this is deferred to the release-notes process.

Fixes A-1688

Comment thread yarn-project/aztec-node/src/modules/node_block_provider.ts Outdated
Comment thread yarn-project/aztec-node/src/modules/node_world_state_queries.ts Outdated
Comment thread yarn-project/aztec-node/src/modules/node_world_state_queries.ts Outdated
import type { BlockHeader } from '@aztec/stdlib/tx';

/** How often a held request re-reads the block source while waiting. */
const POLL_INTERVAL_MS = 200;

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.

I think this might be too high. This adds a minimum increase in latency of 200ms if a block is missing (which is a lot for an API) even if the block arrives 10ms after the query is first attempted. Maybe we can lower it to 50ms?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't want to hammer the poor archiver. Let's compromise at 100ms :-P

Comment on lines +114 to +119
const arrived = await retryUntil(
() => read(query),
`block ${blockParameter}`,
waitMs / 1000,
POLL_INTERVAL_MS / 1000,
);

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.

I'm wondering if a different approach would suit us better where we have a single loop monitoring perpetually monitoring the tips of world state and N=100 queries which get checked against the current tips (sorted by block number so that we cna run binary search on them) vs having 100 individual loops calling the world state.

This is probably something we can only do in v6 where we'll have access to the block number

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually, you make a good point. It's very likely that most held-off requests will be asking for exactly the same block hash (the one most recently mined). So we could group together those requests, as opposed to keeping a loop for each.

Alternatively, we can subscribe to the archiver events, so we don't rely in polling. That's probably a better option. I'll have claude sketch something.

Comment on lines +92 to +95
const value = await read(query);
if (value !== undefined || opts.holdOff === false) {
return value;
}

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.

In v5 there's no way for us to differentiate between 'the next block hasn't arrived yet' vs 'this block number has been reorged', right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If we only have the block hash, nope.

Comment on lines +31 to +32
private readonly blockSource: L2BlockSource,
private readonly holdOff: UnseenBlockHoldOff,

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.

More an interface design than an issue but this class now acts as a router sending some queries to one source and other queries to another. There's no guarantee that both blockSource and holdOff read from the same chain.

Maybe UnseenBlockHoldOff can become a wrapper/proxy implementing the L2BlocKsource interface in v6.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point on the wrapper. Still, given we're the ones constructing this instance and it's pretty contained, I'm not too worried. I'll give it some thought though.

Behind a load balancer a client can sync to block N+1 through one node and then anchor follow-up queries against
another node still at block N, which fails them immediately even though the block lands a moment later. The node now
waits a bounded time for the anchor to arrive before answering: a block number exactly one ahead of the proposed tip
waits up to RPC_UNSEEN_BLOCK_BY_NUMBER_WAIT_MS (default twice the block duration), an unknown block hash or archive
root waits up to RPC_UNSEEN_BLOCK_BY_HASH_WAIT_MS (default 3s), and tags never wait. Setting either to 0 restores
the previous fail-fast behavior.

Outcomes are unchanged, only delayed: on a miss callers still throw or return undefined exactly as before. A cap of
100 simultaneous holds bounds resource use, beyond which misses fail fast. The world-state retry loop only holds off
on its first attempt so a budget is never multiplied by the attempt count.
- Document that a wait budget is approximate: the poll loop sleeps a full interval before re-checking the
  deadline, so the actual wait can overshoot by up to one poll interval plus block-source read latency.
- Replace wall-clock upper bounds with block-source call-count assertions wherever the real signal is "did not
  hold", and loosen the two upper bounds that are genuinely needed. Lower bounds are deterministic and stay.
- Add config-mapping tests covering the unset by-number wait, the by-hash default, and an explicit 0.
- Add elapsedMs to the structured context of the arrived / gave-up log lines.
- Add `getBlock` to `UnseenBlockHoldOff`, which polls the cheap block-data read and then reads the full block
  pinned by the resolved hash, and drop `NodeBlockProvider`'s private hold-off helper.
- Add `getBlockNumber` to `UnseenBlockHoldOff` so `NodeWorldStateQueries` resolves a query to a block number
  through the hold-off instead of unwrapping block data itself.
- Resolve the world-state query once before the sync-retry loop instead of threading a first-attempt flag into
  the resolution: retries re-resolve without holding off, so a resolution miss now propagates without further
  attempts and a client never waits more than one budget.
- Resolve `getBlockHashMembershipWitness`'s reference block through `#resolveBlockNumberAndHash` like every other
  world-state query and drop `#resolveBlockNumber`, which leaves `UnseenBlockHoldOff.getBlockNumber` unused: removed.
- Give `UnseenBlockHoldOff` a single private read-with-hold-off path parameterized by the read to perform, which
  `getBlockData` and `getBlock` fill in. A held query is now polled on the read the caller asked for, instead of
  polling metadata and then reading the block back by the resolved hash.
Adds an automine e2e test where a follower node syncs from L1 only when
its archiver is triggered manually, modeling load-balancer skew: queries
anchored one block past the follower tip (by number and by hash) are
held until the sync is forced, and a query two blocks ahead fails fast
instead of consuming the wait budget.
A client anchors on the genesis block before it has synced any block, as a PXE does for its first tagged-log
queries. The block is synthetic, so a source that does not answer for it now never will and waiting only delays
the answer by a whole by-hash budget.
A lower bound of the same duration as the arrival delay races the clock that delay is scheduled on, and was
observed failing at 199.8ms against a 200ms bound. The extra block-source reads prove the wait just as well.
@alexghr
alexghr force-pushed the spl/a-1688-hold-off-unseen-anchor-v5 branch from 1fdbf27 to 73e7eab Compare August 18, 2026 09:42
@alexghr
alexghr enabled auto-merge August 18, 2026 09:42
@alexghr alexghr added the ci-release-pr Creates a development tag and runs the release suite label Aug 18, 2026
@alexghr
alexghr merged commit 37f86cf into merge-train/spartan-v5 Aug 18, 2026
18 checks passed
@alexghr
alexghr deleted the spl/a-1688-hold-off-unseen-anchor-v5 branch August 18, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-release-pr Creates a development tag and runs the release suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants