Skip to content

fix: improve handleConsistency to wait for tracker polls instead of bailing - #2326

Merged
EliasiOfir merged 4 commits into
mainfrom
claude/near-qos-spec-timing-u6uvk5
Aug 12, 2026
Merged

EliasiOfir merged 4 commits into
mainfrom
claude/near-qos-spec-timing-u6uvk5

Conversation

@EliasiOfir

@EliasiOfir EliasiOfir commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Fixes #2324

Fixes the handleConsistency logic to distinguish between two failure modes:

  1. Provider lag beyond spec tolerance (a node problem): bail immediately
  2. Tracker hasn't polled yet (transient): wait for the next poll instead of bailing

The previous implementation used probabilityBlockError (which models block production) to decide whether to bail. This caused false positives when the spec's average_block_time overstates the chain's real block time, leading to premature bailouts even when the requested blocks already exist on-chain and the tracker would pick them up on its next poll.

The fix gates the early bail on whether there's enough time for at least one tracker poll (trackerPollInterval), which is much faster than block production. This allows the wait loop to run and catch up in cases like NEAR where small gaps (2-3 blocks) are transient.

The spec-level half of #2324 was fixed separately in #2325 (NEAR/NEART average_block_time 1200 -> 600). That change alone is not sufficient: at 600 a blockGap of 2 still bails on 10-CU methods and 3 still bails everywhere. This PR closes the remaining gap.

Changes

  • rpcprovider_server.go: Replace probabilityBlockError > 0.4 condition with noTimeForATrackerPoll check that compares available time against trackerPollInterval
  • consistency_test.go: Add two new test cases:
    • TestHandleConsistency_SmallGapWaitsInsteadOfBailing: Verifies that small gaps (2-3 blocks) wait instead of bail when the tracker can catch up
    • TestHandleConsistency_NoTimeToPollBails: Verifies that we still bail when there's insufficient time for even one tracker poll

Author Checklist

  • read the contribution guide
  • included the correct type prefix in the PR title
  • targeted the main branch
  • reviewed "Files changed" and left comments if necessary
  • included the necessary unit tests
  • updated comments for code documentation
  • confirmed all CI checks have passed

Reviewers Checklist

  • confirmed the correct type prefix in the PR title
  • confirmed all author checklist items have been addressed
  • reviewed state machine logic, API design and naming, documentation is accurate, tests and test coverage

https://claude.ai/code/session_01YCMc3dBY9QkwHkX8H4iK1Q

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix handleConsistency to wait for tracker polls before bailing

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Gate early consistency bail on tracker poll time, not block-production probability.
• Keep probabilityBlockError for observability; add poll timing details to bailout logs.
• Add tests for small-gap catch-up vs immediate bail when no time to poll.
Diagram

graph TD
  A["RPCProviderServer"] --> B["handleConsistency"] --> C["ChainTracker.GetLatestBlockNum"] --> D{"Lag beyond spec?"}
  D -->|"yes"| E["Bail: ConsistencyError"]
  D -->|"no"| F{"No time for poll?"} -->|"yes"| E
  F -->|"no"| G["Wait loop (sleep/poll)"] --> H["Re-check latest"] --> I["Return latest"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Expose poll interval from ChainTracker
  • ➕ Uses the tracker’s actual dynamic timer/backoff behavior instead of recomputing from spec averageBlockTime
  • ➕ Avoids assumptions about MostFrequentPollingMultiplier and future tracker timing changes
  • ➖ Requires adding an API to IChainTracker (interface churn) and threading it through implementations/mocks
  • ➖ Tighter coupling between rpcprovider and chaintracker internals
2. Remove probabilistic modeling entirely from this path
  • ➕ Simplifies reasoning: this path is about tracker polling, not block production
  • ➕ Eliminates reliance on potentially stale spec average_block_time
  • ➖ Loses a potentially useful debug metric (probabilityBlockError) unless replaced with other observability

Recommendation: The PR’s approach is a good minimal-risk fix: it keeps probabilityBlockError for observability but bases the early-bail decision on whether waiting can actually include at least one tracker poll. Consider a future follow-up to expose an actual poll/next-tick estimate from ChainTracker if timer/backoff behavior becomes more dynamic or spec-derived calculations drift.

Files changed (2) +85 / -2

Bug fix (1) +14 / -2
rpcprovider_server.goGate consistency bailout on tracker poll time +14/-2

Gate consistency bailout on tracker poll time

• Replaces the early-bail condition based on probabilityBlockError with a timing gate that checks whether halfTimeLeft can fit at least one chain tracker poll interval. Retains probabilityBlockError for logging and adds halfTimeLeft/trackerPollInterval attributes to bailout warnings.

protocol/rpcprovider/rpcprovider_server.go

Tests (1) +71 / -0
consistency_test.goAdd tests for poll-gated consistency bail vs wait +71/-0

Add tests for poll-gated consistency bail vs wait

• Adds a NEAR-shaped test ensuring small tracker gaps (2–3 blocks) wait and catch up instead of bailing when there is poll budget. Adds a complementary test ensuring immediate bail when the relay timeout cannot accommodate even one tracker poll.

protocol/rpcprovider/consistency_test.go

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Conservative poll interval 🐞 Bug ≡ Correctness
Description
handleConsistency computes trackerPollInterval as averageBlockTime/4 and bails when
halfTimeLeft is smaller, but the ChainTracker’s scheduling can poll as frequently as
averageBlockTime/16. This can return ConsistencyError even when a tracker poll could happen
within the wait budget and the wait loop could succeed.
Code

protocol/rpcprovider/rpcprovider_server.go[R1328-1330]

+	trackerPollInterval := averageBlockTime / (chaintracker.MostFrequentPollingMultiplier / 4)
+	noTimeForATrackerPoll := ok && halfTimeLeft < trackerPollInterval
+	if (blockGap > blockLagForQosSync*2 || (blockGap > 1 && noTimeForATrackerPoll)) && (seenBlock >= latestBlock) {
Evidence
The PR sets the early-bail gate using averageBlockTime/4, but ChainTracker’s own scheduling logic
uses the full multiplier (default 16), meaning the tracker can poll at averageBlockTime/16;
therefore halfTimeLeft can be sufficient for a poll even when the new gate decides it is not.

protocol/rpcprovider/rpcprovider_server.go[1328-1330]
protocol/chaintracker/chain_tracker.go[409-418]
protocol/chaintracker/chain_tracker.go[463-473]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`handleConsistency` uses `trackerPollInterval := averageBlockTime / (chaintracker.MostFrequentPollingMultiplier / 4)` (i.e. `averageBlockTime/4`) to decide whether there is time for at least one chain-tracker poll. However, the chain tracker can poll at `averageBlockTime/chaintracker.MostFrequentPollingMultiplier` (i.e. `averageBlockTime/16`) in its initial and steady-state scheduling.

This makes `noTimeForATrackerPoll` up to 4x too conservative and can cause premature `ConsistencyError` bails when `averageBlockTime/16 <= halfTimeLeft < averageBlockTime/4`.

### Issue Context
The PR goal is to avoid bailing when blocks already exist and the tracker can catch up on its next poll. The current calculation can still bail in cases where a poll could plausibly occur within the remaining wait budget.

### Fix Focus Areas
- protocol/rpcprovider/rpcprovider_server.go[1328-1330]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +1328 to +1330
trackerPollInterval := averageBlockTime / (chaintracker.MostFrequentPollingMultiplier / 4)
noTimeForATrackerPoll := ok && halfTimeLeft < trackerPollInterval
if (blockGap > blockLagForQosSync*2 || (blockGap > 1 && noTimeForATrackerPoll)) && (seenBlock >= latestBlock) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Conservative poll interval 🐞 Bug ≡ Correctness

handleConsistency computes trackerPollInterval as averageBlockTime/4 and bails when
halfTimeLeft is smaller, but the ChainTracker’s scheduling can poll as frequently as
averageBlockTime/16. This can return ConsistencyError even when a tracker poll could happen
within the wait budget and the wait loop could succeed.
Agent Prompt
### Issue description
`handleConsistency` uses `trackerPollInterval := averageBlockTime / (chaintracker.MostFrequentPollingMultiplier / 4)` (i.e. `averageBlockTime/4`) to decide whether there is time for at least one chain-tracker poll. However, the chain tracker can poll at `averageBlockTime/chaintracker.MostFrequentPollingMultiplier` (i.e. `averageBlockTime/16`) in its initial and steady-state scheduling.

This makes `noTimeForATrackerPoll` up to 4x too conservative and can cause premature `ConsistencyError` bails when `averageBlockTime/16 <= halfTimeLeft < averageBlockTime/4`.

### Issue Context
The PR goal is to avoid bailing when blocks already exist and the tracker can catch up on its next poll. The current calculation can still bail in cases where a poll could plausibly occur within the remaining wait budget.

### Fix Focus Areas
- protocol/rpcprovider/rpcprovider_server.go[1328-1330]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@codecov

codecov Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 23.52941% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
protocol/integration/mocks.go 0.00% 12 Missing and 1 partial ⚠️
Flag Coverage Δ
consensus 8.96% <ø> (ø)
protocol 38.68% <23.52%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
protocol/rpcprovider/rpcprovider_server.go 11.39% <100.00%> (+0.39%) ⬆️
protocol/integration/mocks.go 36.96% <0.00%> (-3.80%) ⬇️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
7 files   ±0   0 ❌ ±0 

Results for commit deccf41. ± Comparison against base commit 7d40c96.

♻️ This comment has been updated with latest results.

nimrod-teich
nimrod-teich previously approved these changes Aug 11, 2026

@nimrod-teich nimrod-teich left a comment

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.

LGTM. Rebase to main and push.

…production

handleConsistency's early bail used a Poisson model of block production to
decide whether the provider could reach the consumer's seenBlock. That model
does not describe what this code path waits for.

Every path that reaches the bail targets a block <= seenBlock (blockGap is
clamped to seenBlock - latestBlock just above), and seenBlock was already
served to the consumer by another provider — so those blocks already exist on
chain. The provider is not waiting for the chain to produce blocks, it is
waiting for its own ChainTracker to poll blocks that are already there, which
takes at most averageBlockTime/(MostFrequentPollingMultiplier/4) — well under
one block time.

Modelling it as block production makes the bail fire whenever the spec's
average_block_time overstates the chain's real block time. On NEAR (spec says
1200ms, chain runs at ~600ms since the May 2025 optimistic-block upgrade)
probabilityBlockError evaluates to 0.62-0.97 for every realistic combination
of blockGap >= 2 and relay timeout, so the bail was unconditional and the
catch-up wait below it — which would have closed a 2-3 block gap on the next
poll — was unreachable.

Keep probabilityBlockError for observability and gate the early bail on
whether waiting can help at all:
  - blockGap beyond blockLagForQosSync*2: the node is genuinely behind and
    polling won't fix it, bail immediately (unchanged)
  - not enough time left for a single tracker poll: waiting is pointless
Otherwise fall through to the existing bounded wait, which already re-checks
the tracker every 50ms and whose post-wait check still returns
ConsistencyError if the provider truly did not catch up.

Trade-off: a provider whose node is genuinely behind by <= blockLagForQosSync*2
now spends up to halfTimeLeft before failing instead of failing instantly. That
budget was already spent this way for blockGap == 1, and the consumer races
providers in parallel, so it does not serialize onto end-user latency.

Adds halfTimeLeft and trackerPollInterval to the bail warning so operators can
see which term fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCMc3dBY9QkwHkX8H4iK1Q

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (0a7440f) and pushed — 0d1fb2f → fa15ab3. Note this was a force-push, so it will have reset the approval.

#2322 landed in the meantime, which made this a real rebase rather than a fast-forward. rpcprovider_server.go merged cleanly — that PR's edits to handleConsistency are at the top of the function (reading latestBlock from GetWireLatestBlock) and in the wait loop, while this change is the bail condition between them. consistency_test.go conflicted at the insertion point, where both PRs added new fixtures and tests in the same place. Resolved by keeping both; nothing was dropped or rewritten on either side.

The diff is unchanged at +85/−2, and TestHandleConsistency_ComparesInPublishedDomain from #2322 passes against the new gate — it exercises a 500-block gap against a blockLagForQosSync of 25, so it bails on the far-behind term, which this change leaves untouched.

Verified locally: go build ./... clean; all seven tests in protocol/rpcprovider consistency pass; protocol/rpcprovider/..., protocol/chaintracker/... and protocol/chainlib/... all green.

One open item, flagging rather than silently carrying it: Qodo's Conservative poll interval finding above is unaddressed on purpose. It is correct that trackerPollInterval uses averageBlockTime/4 — updateTimer's slowest branch — while the tracker can poll as fast as averageBlockTime/16, so the gate is conservative and can still bail where waiting would have worked. I measured the practical span: for 10-CU methods it changes behaviour on chains with average_block_time between 2s and 8s, which is 31 specs (the COSMOSSDK/COSMOSHUB/TENDERMINT/IBC family, TRX, EVMOS, XRP, XLM, CELO, TON, TEZOS, CASPER and others). NEAR is unaffected either way at 600ms. Taking that fix would widen this PR's blast radius well beyond the NEAR-shaped case it was scoped to, so it seemed better as a follow-up than as a late change to an approved PR — happy to fold it in here instead if you'd prefer.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

test-protocol failed on d3050ff. Diagnosed as a flake, not this change — re-run triggered.

What failed. protocol/integration died with panic: test timed out after 15m0s, hung in TestConsumerProviderBasic. That is also why the Test Results reporter showed 6 files - 1 — the package never wrote its report. The failure detail doesn't reach the CI log, since test-protocol pipes go test into an artifact and the print step tails a fixed number of lines regardless of outcome, so the log ends on passing packages.

Why it isn't this PR.

A package that is green on both parents and red on a merge that changes no Go code is a flake. protocol/integration is the heaviest package in the tree and it shares a single 15m budget with everything else ./protocol/... runs, so it is the natural one to tip over under runner contention — the same shape as the protocol/lavasession flakes seen on #2323.

One caveat on my own evidence, in the interest of not overstating it: I could not reproduce this locally in a way that discriminates. TestConsumerProviderBasic hangs for the full 15 minutes in my sandbox at every commit I tried, including plain main, because the environment restricts the local servers it stands up. Since main's own CI is green at those same commits, that hang is an artifact of my environment, not a signal about the code. The reasoning above rests on the CI results and the diff, not on the local runs.

If the re-run reproduces it, that changes the picture and I'll dig in properly rather than re-running again.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Correcting my previous comment: the re-run reproduced the failure, so this is not a flake. test-protocol timed out again on d3050ff — the go test step ran ~16 minutes (05:50 → 06:06) and exited 1, which is the -timeout 15m firing. I was wrong to call it a flake and I'm not re-running again.

What the evidence now says. test-protocol is green on both parents and red only on their merge:

commit contents test-protocol
fa15ab3 this change, NEAR spec at 1200/8 green (30/30)
c4c16fe main, NEAR spec at 600/16, without this change green
d3050ff both timeout, twice

The Go code at d3050ff is byte-identical to fa15ab3; the merge added only the two NEAR spec files. So the failure is an interaction between this change and #2325, and neither is faulty alone.

The mechanism, as best I can establish it. This change replaces an instant bail with entry into the existing wait loop, which sleeps up to halfTimeLeft. #2325 raised NEAR's allowed_block_lag_for_qos_sync from 8 to 16, which doubles the far-behind threshold blockLagForQosSync*2 from 16 to 32 — so relays with a gap between 17 and 32 that previously failed instantly now sleep instead. Across an integration suite that runs many relays, added sleeps accumulate against a package budget that was already the tightest in the tree.

That is precisely the fail-fast trade-off noted when this PR was opened. CI is now putting a number on it rather than leaving it theoretical.

What I have not established, and won't assert. I cannot reproduce this locally in a way that discriminates — protocol/integration cannot run in my environment at any commit, including plain main, because it stands up local servers the sandbox blocks. So I know that the interaction is real from the CI matrix above, but I have not directly observed which test slows or by how much. Everything after "the mechanism" is inference from the diff.

Where I think this should go. The most promising fix is to bound the wait by what it is actually waiting for. We established the provider is waiting for one ChainTracker poll, not for block production — so sleeping up to halfTimeLeft (500ms on 10-CU methods) is far longer than needed, and is what makes the accumulated cost bite. Capping the sleep near a poll interval would keep the NEAR benefit while cutting the added latency substantially.

That is a design change to a PR that is already approved, and it interacts with Qodo's base/4 vs base/16 finding above, which pushes the opposite way. I'd rather not guess at it unverified — flagging for a decision on direction before I push anything further.


Generated by Claude Code

protocol/integration times out under `go test -timeout 15m` with no output and
no report file, taking the whole test-protocol job down with it. The hang is in
the first line of the first test, in uniqueAddressGenerator.GetAddress.

Two defects combine:

isPortInUse treats every error from resolve/listen as "this port is taken". A
host without IPv6 loopback fails the [::1] bind with EAFNOSUPPORT, so every port
reports busy even though none of them are.

GetAddress then loops on that, and its `currentPort > maxPort` guard sits after
the loop rather than inside it. When every probe says "in use" the loop never
terminates and never reaches the guard, so the intended
panic("all ports have been exhausted") is unreachable and the package spins
until the test binary is killed.

Fixes both: probe failures that mean the address family is unavailable
(EAFNOSUPPORT, EADDRNOTAVAIL) now skip that address instead of condemning the
port, and the bound is checked inside the loop so exhaustion panics with a
usable message instead of hanging.

Verified on a host without IPv6 loopback, where this reproduces every time:
TestConsumerProviderBasic went from a 15m timeout to passing in 2.09s, and the
full protocol/integration suite from timing out to green in 37s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCMc3dBY9QkwHkX8H4iK1Q
@pull-request-size pull-request-size Bot added size/L and removed size/M labels Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Found the actual cause, and it is neither this change nor an interaction with #2325. My previous two comments were both wrong; disregard them. Fix pushed as ba7cc72.

The hang is in the test helper, on the first line of the first test. Goroutine dump from the timeout:

integration.isPortInUse(...)                 mocks.go:250
integration.(*uniqueAddressGenerator).GetAddress(...)  mocks.go:302
integration.TestConsumerProviderBasic(...)   protocol_test.go:465

Two defects in protocol/integration/mocks.go combine into an infinite loop:

  1. isPortInUse treats every error from resolve/listen as "this port is taken", including errors that say nothing about the port. On a host without IPv6 loopback the [::1] bind fails with EAFNOSUPPORT, so every port reports busy while none of them are.
  2. GetAddress loops on that, and its currentPort > maxPort guard is after the loop rather than inside it. When every probe says "in use" the loop never terminates and never reaches the guard — so the intended panic("all ports have been exhausted") is unreachable and the package spins until go test -timeout 15m kills it. No output, no report file, which is why the reporter showed 6 files - 1 and the log ends on passing packages.

ba7cc72 fixes both: probe failures meaning the address family is unavailable (EAFNOSUPPORT, EADDRNOTAVAIL) skip that address rather than condemning the port, and the bound moves inside the loop so exhaustion panics with a usable message instead of hanging.

Measured, on a host where this reproduces every time:

before after
TestConsumerProviderBasic 15m timeout passes in 2.09s
full protocol/integration 15m timeout green in 37s

The 37s run was at d3050ff exactly — this change plus the merged NEAR spec, the configuration that was failing. So the interaction I hypothesised earlier does not exist: the suite is nowhere near its budget, and the fail-fast trade-off is not costing measurable time here.

One thing I still cannot prove. I reproduced deterministically on a host with no IPv6 loopback. I don't know the runner's IPv6 state, so I can't say for certain the CI hang enters the loop by that same door — the unbounded loop is reachable any time probes keep reporting "in use", and currentPort is package-global and only ever increments across the whole suite. Both halves of the fix are correct regardless, and the loop bound converts any remaining case from a silent 15m hang into an immediate, named panic. If CI still times out after this, it will at least say why.

Happy to split this into its own PR if you'd rather keep this one to the consistency change — it is independent, and it fixes a latent hang that can take down test-protocol on any PR.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

test-protocol is green on ba7cc72 — 6m12s, against two consecutive 15m timeouts before the fix. So the port-probe loop was the cause on the runner too, not only on my host.

A different job went red on the same commit: test-consensus, and it is unrelated to this PR.

FAIL	github.com/lavanet/lava/v5/x/conflict/client/cli	600.099s

A 10-minute timeout, hung in a cosmos-sdk test-network gRPC Accept loop. Two checks that it cannot be this branch:

  • the only new file in ba7cc72 is protocol/integration/mocks.go, which is package integration_test — a test-only package that is never linked into another package's test binary;
  • go list -deps ./x/conflict/client/cli returns zero dependencies on lava/v5/protocol.

test-consensus was also green on fa15ab3, two commits ago on this same branch.

Worth noting it is the same class of problem as the one just fixed — test infrastructure standing up listeners on a constrained runner and hanging rather than failing fast. Different package and different owner, so I'm not touching it here, but x/conflict/client/cli timing out at exactly its 10m budget is worth someone's attention independently of this PR.

Re-run of the failed job is currently rejected with 403 This workflow is already running while the e2e and docker jobs are still in flight; I'll retry once the run settles and report the result either way.


Generated by Claude Code

@EliasiOfir
EliasiOfir merged commit e293c30 into main Aug 12, 2026
31 checks passed
@EliasiOfir
EliasiOfir deleted the claude/near-qos-spec-timing-u6uvk5 branch August 12, 2026 12:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: False consistency rejections and degraded QoS on NEAR/NEART

2 participants