Skip to content

Commit afb2c9b

Browse files
authored
fix: indexer wedging on startup (#3713)
1 parent 5b2e62b commit afb2c9b

2 files changed

Lines changed: 177 additions & 26 deletions

File tree

crates/node/src/indexer.rs

Lines changed: 173 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use near_async::{
2121
use near_client::{RpcHandlerActor, Status, ViewClientActor, client_actor::ClientActor};
2222
use near_indexer::near_primitives::transaction::SignedTransaction;
2323
use near_indexer_primitives::{
24-
types::{BlockReference, Finality},
24+
types::{BlockHeight, BlockReference, Finality},
2525
views::{BlockView, QueryRequest, QueryResponseKind},
2626
};
2727
use near_mpc_contract_interface::method_names::{
@@ -421,28 +421,79 @@ struct IndexerClient {
421421

422422
const INTERVAL: Duration = Duration::from_millis(500);
423423

424+
/// Consecutive non-syncing polls with head progress before the node counts as caught up.
425+
const REQUIRED_STABLE_POLLS: u32 = 4;
426+
424427
impl IndexerClient {
428+
/// Polls sync status, yielding `(syncing, head_height)`, or `None` on a
429+
/// failed request.
430+
async fn sync_info(&self) -> Option<(bool, BlockHeight)> {
431+
let status_request = Status {
432+
is_health_check: false,
433+
detailed: false,
434+
};
435+
let Ok(Ok(status)) = self
436+
.client
437+
.send_async(
438+
near_o11y::span_wrapped_msg::SpanWrappedMessageExt::span_wrap(status_request),
439+
)
440+
.await
441+
else {
442+
return None;
443+
};
444+
Some((
445+
status.sync_info.syncing,
446+
status.sync_info.latest_block_height,
447+
))
448+
}
449+
450+
/// Returns once neard clears its `syncing` flag.
425451
async fn wait_for_full_sync(&self) {
426452
loop {
427453
tokio::time::sleep(INTERVAL).await;
454+
if matches!(self.sync_info().await, Some((false, _))) {
455+
return;
456+
}
457+
}
458+
}
428459

429-
let status_request = Status {
430-
is_health_check: false,
431-
detailed: false,
432-
};
433-
let status_response = self
434-
.client
435-
.send_async(
436-
near_o11y::span_wrapped_msg::SpanWrappedMessageExt::span_wrap(status_request),
437-
)
438-
.await;
460+
async fn ensure_head_follows_tip(&self) {
461+
let mut progress = SyncProgress::default();
462+
loop {
463+
tokio::time::sleep(INTERVAL).await;
464+
if let Some((syncing, head_height)) = self.sync_info().await
465+
&& progress.observe(syncing, head_height)
466+
{
467+
return;
468+
}
469+
}
470+
}
471+
}
439472

440-
let Ok(Ok(status)) = status_response else {
441-
continue;
442-
};
473+
/// Reports caught-up only after [`REQUIRED_STABLE_POLLS`] consecutive
474+
/// non-syncing polls over which the head advances.
475+
#[derive(Default)]
476+
struct SyncProgress {
477+
run_start_head: Option<BlockHeight>,
478+
run_polls: u32,
479+
}
443480

444-
if !status.sync_info.syncing {
445-
return;
481+
impl SyncProgress {
482+
fn observe(&mut self, syncing: bool, head_height: BlockHeight) -> bool {
483+
if syncing {
484+
self.run_start_head = None;
485+
self.run_polls = 0;
486+
return false;
487+
}
488+
match self.run_start_head {
489+
None => {
490+
self.run_start_head = Some(head_height);
491+
self.run_polls = 1;
492+
false
493+
}
494+
Some(start_head) => {
495+
self.run_polls += 1;
496+
self.run_polls >= REQUIRED_STABLE_POLLS && head_height > start_head
446497
}
447498
}
448499
}
@@ -503,3 +554,109 @@ pub struct IndexerAPI<TransactionSender, ForeignChainPolicyReader> {
503554

504555
pub foreign_chain_policy_reader: ForeignChainPolicyReader,
505556
}
557+
558+
#[cfg(test)]
559+
#[expect(non_snake_case)]
560+
mod tests {
561+
use super::{BlockHeight, REQUIRED_STABLE_POLLS, SyncProgress};
562+
563+
fn first_caught_up_poll(samples: &[(bool, BlockHeight)]) -> Option<usize> {
564+
let mut progress = SyncProgress::default();
565+
samples
566+
.iter()
567+
.position(|&(syncing, head)| progress.observe(syncing, head))
568+
}
569+
570+
#[test]
571+
fn observe__should_never_report_caught_up_while_syncing() {
572+
// Given
573+
let samples: Vec<_> = (0..10).map(|i| (true, 42_000_000 + i)).collect();
574+
575+
// When
576+
let caught_up_at = first_caught_up_poll(&samples);
577+
578+
// Then
579+
assert_eq!(caught_up_at, None);
580+
}
581+
582+
#[test]
583+
fn observe__should_never_report_caught_up_with_static_head_at_genesis() {
584+
// Given
585+
let genesis = 42_376_888;
586+
let samples: Vec<_> = (0..10).map(|_| (false, genesis)).collect();
587+
588+
// When
589+
let caught_up_at = first_caught_up_poll(&samples);
590+
591+
// Then
592+
assert_eq!(caught_up_at, None);
593+
}
594+
595+
#[test]
596+
fn observe__should_not_report_caught_up_before_required_polls() {
597+
// Given
598+
let samples: Vec<_> = (0..REQUIRED_STABLE_POLLS - 1)
599+
.map(|i| (false, 257_000_000 + u64::from(i)))
600+
.collect();
601+
602+
// When
603+
let caught_up_at = first_caught_up_poll(&samples);
604+
605+
// Then
606+
assert_eq!(caught_up_at, None);
607+
}
608+
609+
#[test]
610+
fn observe__should_report_caught_up_after_sustained_progress() {
611+
// Given
612+
let samples: Vec<_> = (0..REQUIRED_STABLE_POLLS)
613+
.map(|i| (false, 257_000_000 + u64::from(i)))
614+
.collect();
615+
616+
// When
617+
let caught_up_at = first_caught_up_poll(&samples);
618+
619+
// Then
620+
let expected = usize::try_from(REQUIRED_STABLE_POLLS).unwrap() - 1;
621+
assert_eq!(caught_up_at, Some(expected));
622+
}
623+
624+
#[test]
625+
fn observe__should_wait_for_head_to_advance_past_run_start() {
626+
// Given
627+
let head = 257_000_000;
628+
let mut samples: Vec<_> = (0..REQUIRED_STABLE_POLLS + 2)
629+
.map(|_| (false, head))
630+
.collect();
631+
samples.push((false, head + 1));
632+
633+
// When
634+
let caught_up_at = first_caught_up_poll(&samples);
635+
636+
// Then
637+
assert_eq!(caught_up_at, Some(samples.len() - 1));
638+
}
639+
640+
#[test]
641+
fn observe__should_reset_run_when_syncing_resumes() {
642+
// Given
643+
let pre = [
644+
(false, 42_000_000),
645+
(false, 42_000_001),
646+
(false, 42_000_002),
647+
];
648+
let resync = [(true, 100_000_000)];
649+
let post: Vec<_> = (0..REQUIRED_STABLE_POLLS)
650+
.map(|i| (false, 257_000_000 + u64::from(i)))
651+
.collect();
652+
let samples: Vec<_> = pre.iter().chain(&resync).chain(&post).copied().collect();
653+
654+
// When
655+
let caught_up_at = first_caught_up_poll(&samples);
656+
657+
// Then
658+
let expected =
659+
pre.len() + resync.len() + usize::try_from(REQUIRED_STABLE_POLLS).unwrap() - 1;
660+
assert_eq!(caught_up_at, Some(expected));
661+
}
662+
}

crates/node/src/indexer/real.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,11 @@ pub fn spawn_real_indexer(
143143

144144
tracing::info!("Indexer waiting for node to finish syncing before streaming blocks.");
145145

146-
// Defer all indexing work until the node has finished syncing. On a
147-
// node that state-syncs from scratch, neard sits at genesis until
148-
// sync completes; starting the streamer then pins its cursor at
149-
// genesis (`LatestSynced`), below the node's block tail, so it can
150-
// never reach the chain tip. (#3623)
151-
//
152-
// The wait is raced against `shutdown_token` so a SIGTERM during the
153-
// (possibly long) initial state sync still tears the thread down
154-
// cleanly instead of blocking until sync finishes.
146+
// Streaming before the node is synced pins the `LatestSynced` cursor
147+
// at genesis, below the block tail it can never reach. Raced against
148+
// shutdown so a SIGTERM during state sync still tears down cleanly.
155149
if !await_sync_or_shutdown(
156-
indexer_state.client.wait_for_full_sync(),
150+
indexer_state.client.ensure_head_follows_tip(),
157151
&shutdown_token,
158152
)
159153
.await

0 commit comments

Comments
 (0)