Skip to content

Commit fd97994

Browse files
committed
fix: indexer wedging on startup
1 parent 6ff1b06 commit fd97994

5 files changed

Lines changed: 372 additions & 20 deletions

File tree

crates/chain-gateway/src/chain_gateway.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::near_internals_wrapper::{
1616
NearClientActorHandle, NearRpcActorHandle, NearViewClientActorHandle,
1717
};
1818
use crate::primitives::{
19-
FetchLatestFinalBlockInfo, IsSyncing, QueryViewFunction, SubmitSignedTransaction,
19+
FetchLatestFinalBlockInfo, IsSyncing, QueryViewFunction, SubmitSignedTransaction, SyncStatus,
2020
};
2121
use crate::types::ObservedState;
2222

@@ -32,8 +32,8 @@ pub struct ChainGateway {
3232

3333
impl IsSyncing for ChainGateway {
3434
type Error = NearClientError;
35-
async fn is_syncing(&self) -> Result<bool, Self::Error> {
36-
self.client.is_syncing().await
35+
async fn sync_status(&self) -> Result<SyncStatus, Self::Error> {
36+
self.client.sync_status().await
3737
}
3838
}
3939

crates/chain-gateway/src/mock.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::primitives::{
2-
FetchLatestFinalBlockInfo, IsSyncing, QueryViewFunction, SubmitSignedTransaction,
2+
FetchLatestFinalBlockInfo, IsSyncing, QueryViewFunction, SubmitSignedTransaction, SyncStatus,
33
};
44
use crate::types::{LatestFinalBlockInfo, ObservedState};
55
use near_account_id::AccountId;
@@ -137,8 +137,23 @@ impl MockChainStateBuilder {
137137

138138
impl IsSyncing for MockChainState {
139139
type Error = MockError;
140-
async fn is_syncing(&self) -> Result<bool, Self::Error> {
141-
self.sync_response.lock().unwrap().clone()
140+
async fn sync_status(&self) -> Result<SyncStatus, Self::Error> {
141+
// The mock is driven by a single "is it syncing" bool: `false` maps to a
142+
// caught-up head, `true` to a head still behind its peers.
143+
let syncing = self.sync_response.lock().unwrap().clone()?;
144+
Ok(if syncing {
145+
SyncStatus {
146+
syncing: true,
147+
head_height: 0,
148+
max_peer_height: Some(100),
149+
}
150+
} else {
151+
SyncStatus {
152+
syncing: false,
153+
head_height: 100,
154+
max_peer_height: Some(100),
155+
}
156+
})
142157
}
143158
}
144159

crates/chain-gateway/src/near_internals_wrapper/client.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ use std::sync::Arc;
22

33
use near_async::messaging::CanSendAsync as _;
44

5-
use crate::{errors::NearClientError, primitives::IsSyncing};
5+
use crate::{
6+
errors::NearClientError,
7+
primitives::{IsSyncing, SyncStatus},
8+
};
69

710
/// Arc-wrapper around near-internal struct
811
#[derive(Clone)]
@@ -23,12 +26,14 @@ impl NearClientActorHandle {
2326
/// Implement IsSyncing for our near client
2427
impl IsSyncing for NearClientActorHandle {
2528
type Error = NearClientError;
26-
async fn is_syncing(&self) -> Result<bool, Self::Error> {
29+
async fn sync_status(&self) -> Result<SyncStatus, Self::Error> {
30+
// `detailed: true` so the response carries connected-peer heights, which
31+
// we use to confirm the head has actually caught up.
2732
let status_request = near_client::Status {
2833
is_health_check: false,
29-
detailed: false,
34+
detailed: true,
3035
};
31-
let status = &self
36+
let status = self
3237
.client
3338
.send_async(
3439
near_o11y::span_wrapped_msg::SpanWrappedMessageExt::span_wrap(status_request),
@@ -40,6 +45,18 @@ impl IsSyncing for NearClientActorHandle {
4045
.map_err(|err| NearClientError::ResponseError {
4146
message: err.to_string(),
4247
})?;
43-
Ok(status.sync_info.syncing)
48+
let max_peer_height = status.detailed_debug_status.as_ref().and_then(|detailed| {
49+
detailed
50+
.network_info
51+
.connected_peers
52+
.iter()
53+
.filter_map(|peer| peer.height)
54+
.max()
55+
});
56+
Ok(SyncStatus {
57+
syncing: status.sync_info.syncing,
58+
head_height: status.sync_info.latest_block_height,
59+
max_peer_height,
60+
})
4461
}
4562
}

crates/chain-gateway/src/primitives.rs

Lines changed: 174 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,60 @@ use near_indexer::near_primitives::transaction::SignedTransaction;
1010
use std::future::Future;
1111
use std::time::Duration;
1212

13+
/// Snapshot of the node's sync progress relative to its peers.
14+
pub(crate) struct SyncStatus {
15+
/// Whether the node currently reports itself as syncing.
16+
pub syncing: bool,
17+
/// Height of the node's local head.
18+
pub head_height: u64,
19+
/// Highest block height advertised by a connected peer, if any.
20+
pub max_peer_height: Option<u64>,
21+
}
22+
23+
/// How far behind the highest peer the local head may be while still counting
24+
/// as caught up. Absorbs peers advancing a block or two between polls; the
25+
/// `syncing` flag carries the steady state.
26+
const SYNC_HEIGHT_TOLERANCE: u64 = 5;
27+
28+
impl SyncStatus {
29+
/// Whether the node is fully synced to the network head.
30+
///
31+
/// The `syncing` flag alone is insufficient: on a freshly state-syncing
32+
/// node (or one returning from long downtime) it reads `false` during the
33+
/// startup window before the node has learned it is behind, which would pin
34+
/// the streamer's `LatestSynced` cursor at a stale head it can never reach.
35+
/// We also require the head to be within [`SYNC_HEIGHT_TOLERANCE`] of the
36+
/// highest connected peer. While no peer reports a height we cannot confirm
37+
/// we are caught up, so we keep waiting.
38+
fn is_caught_up(&self) -> bool {
39+
if self.syncing {
40+
return false;
41+
}
42+
match self.max_peer_height {
43+
None => false,
44+
Some(peer_height) => {
45+
peer_height.saturating_sub(self.head_height) <= SYNC_HEIGHT_TOLERANCE
46+
}
47+
}
48+
}
49+
}
50+
1351
/// Low-level trait for checking indexer sync status.
1452
pub(crate) trait IsSyncing: Send + Sync + 'static {
1553
type Error: std::error::Error + Send + Sync + 'static;
16-
/// Returns whether the node is currently syncing.
17-
fn is_syncing(&self) -> impl Future<Output = Result<bool, Self::Error>> + Send;
54+
/// Returns the node's current sync progress relative to its peers.
55+
fn sync_status(&self) -> impl Future<Output = Result<SyncStatus, Self::Error>> + Send;
1856

1957
const INTERVAL: Duration = Duration::from_millis(500);
20-
/// Polls [`is_syncing`](Self::is_syncing) until the node is fully synced.
58+
/// Polls [`sync_status`](Self::sync_status) until the node has caught up to
59+
/// the network head.
2160
fn wait_for_full_sync(&self) -> impl Future<Output = ()> + Send {
2261
async {
2362
let mut attempt = 0u32;
2463
loop {
25-
match self.is_syncing().await {
26-
Ok(false) => return,
27-
Ok(true) => {
64+
match self.sync_status().await {
65+
Ok(status) if status.is_caught_up() => return,
66+
Ok(_) => {
2867
if attempt.is_multiple_of(120) {
2968
tracing::info!("has been syncing for: {} seconds", attempt / 2);
3069
}
@@ -65,3 +104,132 @@ pub(crate) trait SubmitSignedTransaction: Send + Sync + 'static {
65104
transaction: SignedTransaction,
66105
) -> impl Future<Output = Result<(), Self::Error>> + Send;
67106
}
107+
108+
#[cfg(test)]
109+
#[expect(non_snake_case)]
110+
mod tests {
111+
use super::{SYNC_HEIGHT_TOLERANCE, SyncStatus};
112+
113+
#[test]
114+
fn is_caught_up__should_be_false_while_node_reports_syncing() {
115+
// Given
116+
let status = SyncStatus {
117+
syncing: true,
118+
head_height: 257_000_000,
119+
max_peer_height: Some(257_000_000),
120+
};
121+
122+
// When
123+
let caught_up = status.is_caught_up();
124+
125+
// Then
126+
assert!(!caught_up);
127+
}
128+
129+
/// The #3623 wedge: at fresh boot the node sits at genesis with `syncing`
130+
/// transiently `false` before it has learned a peer is far ahead.
131+
#[test]
132+
fn is_caught_up__should_be_false_at_genesis_before_sync_starts() {
133+
// Given
134+
let status = SyncStatus {
135+
syncing: false,
136+
head_height: 42_376_888,
137+
max_peer_height: Some(257_409_058),
138+
};
139+
140+
// When
141+
let caught_up = status.is_caught_up();
142+
143+
// Then
144+
assert!(!caught_up);
145+
}
146+
147+
/// Same wedge after long downtime: the stale head is far above genesis but
148+
/// still far below the peers.
149+
#[test]
150+
fn is_caught_up__should_be_false_with_stale_head_far_above_genesis() {
151+
// Given
152+
let status = SyncStatus {
153+
syncing: false,
154+
head_height: 200_000_000,
155+
max_peer_height: Some(257_409_058),
156+
};
157+
158+
// When
159+
let caught_up = status.is_caught_up();
160+
161+
// Then
162+
assert!(!caught_up);
163+
}
164+
165+
/// Until a peer advertises a height we cannot confirm we are caught up.
166+
#[test]
167+
fn is_caught_up__should_be_false_when_no_peer_height_known() {
168+
// Given
169+
let status = SyncStatus {
170+
syncing: false,
171+
head_height: 257_409_058,
172+
max_peer_height: None,
173+
};
174+
175+
// When
176+
let caught_up = status.is_caught_up();
177+
178+
// Then
179+
assert!(!caught_up);
180+
}
181+
182+
#[test]
183+
fn is_caught_up__should_be_true_when_head_reaches_peer_height() {
184+
// Given
185+
let peer_head = 257_409_058;
186+
let status = SyncStatus {
187+
syncing: false,
188+
head_height: peer_head,
189+
max_peer_height: Some(peer_head),
190+
};
191+
192+
// When
193+
let caught_up = status.is_caught_up();
194+
195+
// Then
196+
assert!(caught_up);
197+
}
198+
199+
/// Peers may advance a few blocks between polls; being within the tolerance
200+
/// still counts as caught up.
201+
#[test]
202+
fn is_caught_up__should_be_true_within_tolerance_of_peer_height() {
203+
// Given
204+
let peer_head = 257_409_058;
205+
let status = SyncStatus {
206+
syncing: false,
207+
head_height: peer_head - SYNC_HEIGHT_TOLERANCE,
208+
max_peer_height: Some(peer_head),
209+
};
210+
211+
// When
212+
let caught_up = status.is_caught_up();
213+
214+
// Then
215+
assert!(caught_up);
216+
}
217+
218+
/// A node slightly ahead of the peers it currently sees is caught up.
219+
#[test]
220+
fn is_caught_up__should_be_true_when_head_above_peer_height() {
221+
// Given
222+
let peer_head = 257_409_058;
223+
let status = SyncStatus {
224+
syncing: false,
225+
head_height: peer_head + 10,
226+
max_peer_height: Some(peer_head),
227+
};
228+
229+
// When
230+
let caught_up = status.is_caught_up();
231+
232+
// Then
233+
assert!(caught_up);
234+
}
235+
}

0 commit comments

Comments
 (0)