Skip to content

fix(syncer): refetch latest da height instead of da height +1#3201

Merged
tac0turtle merged 11 commits intomainfrom
julien/cache
Mar 26, 2026
Merged

fix(syncer): refetch latest da height instead of da height +1#3201
tac0turtle merged 11 commits intomainfrom
julien/cache

Conversation

@julienrbrt
Copy link
Member

@julienrbrt julienrbrt commented Mar 24, 2026

Overview

If the latest da height contained more blocks, and one of those blocks got executed, without p2p the node will be stuck as it won't refetch the da heigth. We should re fetch daHeight-1 on start.

In our case, the cache was as well further, which is fine, but because we don't hold blocks in cache, we don't need this at all.

Summary by CodeRabbit

  • Bug Fixes

    • Refetches the latest DA height (avoiding an off-by-one when P2P is offline), improving startup sync reliability.
  • Documentation

    • Updated Unreleased changelog ordering and fixed-entry placement to reflect the DA height fix.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

Syncer startup DA-height initialization was changed to derive the daRetrieverHeight from the last executed block's included DA heights (when available) instead of directly using the cache-wide DaHeight(). Tests were updated to provide deterministic mock store heights; changelog updated with a related fixed item.

Changes

Cohort / File(s) Summary
Documentation
CHANGELOG.md
Moved/added ### Fixed under Unreleased and documented the correction: "Refetch latest da height instead of da height +1 when P2P is offline".
Core Syncer Logic
block/internal/syncing/syncer.go
Changed initialization of daRetrieverHeight to compute from last executed block (use cached inclusion heights for state.LastBlockHeight when present) rather than unconditionally taking max(genesis.DAStartHeight, cache.DaHeight(), state.DAHeight).
Tests / Mocks
block/internal/syncing/syncer_forced_inclusion_test.go, block/internal/syncing/syncer_test.go
Refactored tests to pass explicit mock header/data stores into NewSyncer and added .Maybe() expectations for Height() returning 0; adjusted one DA-client mock return from 300 to 100 in a test.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • tac0turtle

Poem

🐰 I nibbled logs and chased a height,

When P2P slept through the night.
Now the syncer peeks the last block's clue,
No more guessing what DA will do.
Tests are snug, mocks set to zero—hip hooray!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: refetching the latest DA height instead of DA height + 1 during syncer initialization.
Description check ✅ Passed The description explains the problem and rationale but lacks issue linkage and could be more detailed about the technical solution.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch julien/cache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Contributor

claude bot commented Mar 24, 2026

Claude finished @julienrbrt's task in 2m 57s —— View job


Code Review

  • Gather context and read changed files
  • Analyze syncer.go changes for correctness
  • Review test changes
  • Check for bugs and edge cases
  • Post comprehensive review

Summary

The PR's intent is correct — restarting from daHeight-1 instead of daHeight+1 prevents the node from missing blocks already included at state.DAHeight when P2P is offline. However, there's a critical logic bug in the implementation.


🔴 Critical: min(state.DAHeight-1, 0) always evaluates to 0

block/internal/syncing/syncer.go:359

// Current (broken):
daHeight := max(s.genesis.DAStartHeight, min(state.DAHeight-1, 0))

Since state.DAHeight is uint64, the constant 0 in min(..., 0) is also uint64. The minimum possible uint64 value is 0, so min(any_uint64, 0) is always 0. This means state.DAHeight is silently ignored on every restart — the node always starts from s.genesis.DAStartHeight, which defeats the entire purpose of this fix.

Additionally, if state.DAHeight == 0, state.DAHeight - 1 would underflow (wrapping to math.MaxUint64). The safe fix:

var daHeight uint64
if state.DAHeight > 0 {
    daHeight = state.DAHeight - 1
}
daHeight = max(s.genesis.DAStartHeight, daHeight)

Fix this →


🟡 Minor: Unused mocks with wrong types in TestSyncLoopPersistState

block/internal/syncing/syncer_test.go:370-374

// These are declared but never passed to NewSyncer — the P2P variants below are used instead.
// Worse, the types are wrong: SignedHeader/Data instead of P2PSignedHeader/P2PData.
mockHeaderStore := extmocks.NewMockStore[*types.SignedHeader](t)   // ← wrong type, unused
mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe()
mockDataStore := extmocks.NewMockStore[*types.Data](t)             // ← wrong type, unused
mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe()

These are dead code left over from a refactor. They should be deleted — mockP2PHeaderStore and mockP2PDataStore (lines 376-379) are the ones actually used.


✅ What's good

  • The overall approach is sound: using GetHeaderDAIncludedByHeight/GetDataDAIncludedByHeight for the last executed block is the right way to recover DA height on restart without relying on P2P-sourced cache entries that may not survive.
  • Refactoring tests to use explicit named mocks (instead of inline NewMockStore calls) is a nice readability improvement — .Maybe() expectations are now clearer.
  • The TestProcessHeightEvent_SkipsDAHintWhenBelowRetrieverCursor change from DA height 300 → 100 is correct: since daRetrieverHeight is set to 150 in that test and hints are at 100, the mock returning 300 was misleading (the test would still pass, but 100 is more accurate to the scenario being tested).

🧪 Missing test coverage

There's no test that exercises the core bug scenario: a node restarting with state.DAHeight > 1 and no P2P (i.e., the cache lookups for the last block both return ok=false). In that path, daHeight should equal state.DAHeight - 1, but currently it would be 0. A test for initializeState with state.DAHeight = 10 and empty cache would catch this regression.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 24, 2026

The latest Buf updates on your PR. Results from workflow CI / buf-check (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed⏩ skipped✅ passed✅ passedMar 26, 2026, 10:51 AM

@julienrbrt julienrbrt marked this pull request as draft March 24, 2026 18:46
if s.headerStore != nil && s.headerStore.Height() > state.LastBlockHeight {
daHeight = max(daHeight, s.cache.DaHeight())
}
s.daRetrieverHeight.Store(daHeight)
Copy link
Contributor

Choose a reason for hiding this comment

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

makes sense

@julienrbrt julienrbrt marked this pull request as ready for review March 26, 2026 09:26
@codecov
Copy link

codecov bot commented Mar 26, 2026

Codecov Report

❌ Patch coverage is 42.85714% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.42%. Comparing base (4c8757c) to head (5ca5224).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
block/internal/syncing/syncer.go 42.85% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3201      +/-   ##
==========================================
- Coverage   61.45%   61.42%   -0.03%     
==========================================
  Files         120      120              
  Lines       12443    12449       +6     
==========================================
  Hits         7647     7647              
- Misses       3939     3942       +3     
- Partials      857      860       +3     
Flag Coverage Δ
combined 61.42% <42.85%> (-0.03%) ⬇️

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

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
block/internal/syncing/syncer_test.go (1)

370-379: ⚠️ Potential issue | 🟡 Minor

Type mismatch: mockHeaderStore and mockDataStore use wrong type parameters.

In this test, mockHeaderStore is typed as MockStore[*types.SignedHeader] and mockDataStore as MockStore[*types.Data], but NewSyncer expects header.Store[*types.P2PSignedHeader] and header.Store[*types.P2PData]. The correct mocks (mockP2PHeaderStore and mockP2PDataStore) are created on lines 376-379 and correctly used in NewSyncer calls, but mockHeaderStore and mockDataStore on lines 370-374 appear to be unused artifacts.

🧹 Suggested cleanup: remove unused mock declarations
-	// Create mock stores for P2P
-	mockHeaderStore := extmocks.NewMockStore[*types.SignedHeader](t)
-	mockHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe()
-
-	mockDataStore := extmocks.NewMockStore[*types.Data](t)
-	mockDataStore.EXPECT().Height().Return(uint64(0)).Maybe()
-
 	mockP2PHeaderStore := extmocks.NewMockStore[*types.P2PSignedHeader](t)
 	mockP2PHeaderStore.EXPECT().Height().Return(uint64(0)).Maybe()
 	mockP2PDataStore := extmocks.NewMockStore[*types.P2PData](t)
 	mockP2PDataStore.EXPECT().Height().Return(uint64(0)).Maybe()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@block/internal/syncing/syncer_test.go` around lines 370 - 379, Remove the
unused, incorrectly-typed mocks by deleting the mockHeaderStore and
mockDataStore declarations (symbols: mockHeaderStore, mockDataStore) in
syncer_test.go; ensure the test relies only on the existing mockP2PHeaderStore
and mockP2PDataStore used when constructing NewSyncer so there are no leftover
mock artifacts or type mismatches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@block/internal/syncing/syncer.go`:
- Around line 358-363: The computed daHeight uses min(state.DAHeight-1, 0) which
always yields 0 for non-negative DA heights; change this to clamp the underflow
instead by using max(state.DAHeight-1, 0) so the line becomes daHeight :=
max(s.genesis.DAStartHeight, max(state.DAHeight-1, 0)); update the expression in
syncer.go (referencing daHeight, s.genesis.DAStartHeight, state.DAHeight,
s.cache.DaHeight(), s.headerStore.Height(), and s.daRetrieverHeight.Store) and
ensure any necessary type conversions are applied so the subtraction and
comparison do not underflow for unsigned types.

---

Outside diff comments:
In `@block/internal/syncing/syncer_test.go`:
- Around line 370-379: Remove the unused, incorrectly-typed mocks by deleting
the mockHeaderStore and mockDataStore declarations (symbols: mockHeaderStore,
mockDataStore) in syncer_test.go; ensure the test relies only on the existing
mockP2PHeaderStore and mockP2PDataStore used when constructing NewSyncer so
there are no leftover mock artifacts or type mismatches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a076008c-af59-4027-9645-bc9cab4f1083

📥 Commits

Reviewing files that changed from the base of the PR and between 4c8757c and 5d28492.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • block/internal/syncing/syncer.go
  • block/internal/syncing/syncer_forced_inclusion_test.go
  • block/internal/syncing/syncer_test.go

Comment on lines +358 to +363
// Only use cache.DaHeight() when P2P is actively syncing (headerStore has higher height than current state).
daHeight := max(s.genesis.DAStartHeight, min(state.DAHeight-1, 0))
if s.headerStore != nil && s.headerStore.Height() > state.LastBlockHeight {
daHeight = max(daHeight, s.cache.DaHeight())
}
s.daRetrieverHeight.Store(daHeight)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: min(state.DAHeight-1, 0) always evaluates to 0, ignoring the stored DA height.

The expression min(state.DAHeight-1, 0) will always return 0 when state.DAHeight >= 1 because min(positive_value, 0) is 0. This makes line 359 effectively equivalent to:

daHeight := max(s.genesis.DAStartHeight, 0) // state.DAHeight is ignored

This contradicts the PR objective of refetching DA height - 1. The intent appears to be using max to clamp the underflow, not min.

🐛 Proposed fix to correctly compute DA height - 1 with underflow protection
 	// Set DA height to the maximum of the genesis start height, the state's DA height, and the cached DA height.
 	// The cache's DaHeight() is initialized from store metadata, so it's always correct even after cache clear.
 	// Only use cache.DaHeight() when P2P is actively syncing (headerStore has higher height than current state).
-	daHeight := max(s.genesis.DAStartHeight, min(state.DAHeight-1, 0))
+	var daHeight uint64
+	if state.DAHeight > 0 {
+		daHeight = state.DAHeight - 1
+	}
+	daHeight = max(s.genesis.DAStartHeight, daHeight)
 	if s.headerStore != nil && s.headerStore.Height() > state.LastBlockHeight {
 		daHeight = max(daHeight, s.cache.DaHeight())
 	}
 	s.daRetrieverHeight.Store(daHeight)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@block/internal/syncing/syncer.go` around lines 358 - 363, The computed
daHeight uses min(state.DAHeight-1, 0) which always yields 0 for non-negative DA
heights; change this to clamp the underflow instead by using
max(state.DAHeight-1, 0) so the line becomes daHeight :=
max(s.genesis.DAStartHeight, max(state.DAHeight-1, 0)); update the expression in
syncer.go (referencing daHeight, s.genesis.DAStartHeight, state.DAHeight,
s.cache.DaHeight(), s.headerStore.Height(), and s.daRetrieverHeight.Store) and
ensure any necessary type conversions are applied so the subtraction and
comparison do not underflow for unsigned types.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
block/internal/syncing/syncer.go (1)

359-359: ⚠️ Potential issue | 🟠 Major

Line 359 still drops the persisted DA height on cache miss.

min(state.DAHeight-1, 0) always yields 0 for a uint64, and state.DAHeight == 0 underflows before min runs. So if the per-block cache lookup misses, startup falls back to s.genesis.DAStartHeight instead of state.DAHeight-1, which defeats the recovery path this change is trying to add.

🐛 Proposed fix
-	daHeight := max(s.genesis.DAStartHeight, min(state.DAHeight-1, 0))
+	daHeight := s.genesis.DAStartHeight
+	if state.DAHeight > 0 {
+		daHeight = max(daHeight, state.DAHeight-1)
+	}
In Go, when `stateDAHeight` is a `uint64`, what are the results of `min(stateDAHeight-1, 0)` for (a) `stateDAHeight = 5` and (b) `stateDAHeight = 0`? Please cite the rules for unsigned integer arithmetic and the predeclared `min` function.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@block/internal/syncing/syncer.go` at line 359, The expression daHeight :=
max(s.genesis.DAStartHeight, min(state.DAHeight-1, 0)) is incorrect for uint64:
state.DAHeight-1 underflows when state.DAHeight==0 and min(...,0) always yields
0, dropping the intended state.DAHeight-1 fallback; fix by computing the
previous DA height safely (e.g., prev := uint64(0); if state.DAHeight > 0 { prev
= state.DAHeight - 1 }) and then use daHeight := max(s.genesis.DAStartHeight,
prev) so the code in syncer.go uses a safe prev value derived from
state.DAHeight rather than relying on min/max to handle unsigned underflow.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@block/internal/syncing/syncer.go`:
- Line 359: The expression daHeight := max(s.genesis.DAStartHeight,
min(state.DAHeight-1, 0)) is incorrect for uint64: state.DAHeight-1 underflows
when state.DAHeight==0 and min(...,0) always yields 0, dropping the intended
state.DAHeight-1 fallback; fix by computing the previous DA height safely (e.g.,
prev := uint64(0); if state.DAHeight > 0 { prev = state.DAHeight - 1 }) and then
use daHeight := max(s.genesis.DAStartHeight, prev) so the code in syncer.go uses
a safe prev value derived from state.DAHeight rather than relying on min/max to
handle unsigned underflow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ebcf320-c385-48b7-b906-5022b5c12b28

📥 Commits

Reviewing files that changed from the base of the PR and between 5d28492 and 5ca5224.

📒 Files selected for processing (1)
  • block/internal/syncing/syncer.go

@tac0turtle tac0turtle merged commit 47ed4ed into main Mar 26, 2026
50 of 53 checks passed
@tac0turtle tac0turtle deleted the julien/cache branch March 26, 2026 12:37
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.

3 participants