Skip to content

Commit ac7c174

Browse files
prestwichclaude
andauthored
fix(host-rpc): ingest [from, tip] when backfill_from is above ceiling (#148)
* fix(host-rpc): ingest [from, tip] when backfill_from is above ceiling When the sidecar restarted with its persisted host height within `buffer_capacity/2` blocks of tip, `drain_backfill` declared backfill complete without fetching any blocks. The first subsequent `handle_new_head` then walked from an empty `chain_view`, returning a single-block segment whose RU parent was not in the DB — crashing the processor with "parent ru block not present in DB" until the chain advanced past the ceiling (5–10 minutes). The `/2` ceiling exists to leave hash-walk headroom on new tips, but it must not drop blocks the node has already committed to ingesting. When `from > backfill_ceiling` but `from <= tip`, ingest up to `tip` in this batch instead of skipping. Fixes ENG-2280. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(host-rpc): error on backfill cursor past tip+1 Provider tip regression (reorg, lagging replica) would silently switch to frontfill from an inconsistent cursor, re-introducing the parent-not-in-DB crash this branch addresses. Only `from == tip + 1` is a legitimate completion; `from > tip + 1` now returns `BackfillContinuityBreak`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5e85090 commit ac7c174

1 file changed

Lines changed: 51 additions & 9 deletions

File tree

‎crates/host-rpc/src/notifier.rs‎

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use alloy::{
1111
use futures_util::{StreamExt, TryStreamExt, stream};
1212
use signet_node_types::{HostNotification, HostNotificationKind, HostNotifier, RevertRange};
1313
use signet_types::primitives::{RecoveredBlock, SealedBlock, SignetHeaderV1, TransactionSigned};
14-
use std::{collections::VecDeque, sync::Arc, time::Instant};
14+
use std::{cmp::Ordering, collections::VecDeque, sync::Arc, time::Instant};
1515
use tracing::{debug, info, warn};
1616

1717
/// Default seconds per slot (Ethereum mainnet).
@@ -506,7 +506,29 @@ where
506506
/// Process a backfill batch if pending.
507507
///
508508
/// Backfills by number up to `(latest - buffer_capacity / 2)` to leave
509-
/// half the buffer depth for hash-based frontfill of recent blocks.
509+
/// half the buffer depth for hash-based frontfill of recent blocks. If
510+
/// `backfill_from` is already inside that headroom, the ceiling is
511+
/// raised to `tip` so the next frontfill segment's parent is in the DB.
512+
///
513+
/// # Return values
514+
///
515+
/// - `None` — backfill is complete (no pending cursor, or the cursor
516+
/// has reached `tip + 1`). `backfill_from` is cleared and the caller
517+
/// should advance to frontfill via the `newHeads` subscription.
518+
/// - `Some(Ok(notification))` — a batch was fetched successfully; the
519+
/// notification carries the chain segment for `[from, to]`.
520+
/// `backfill_from` is advanced to `to + 1`, or cleared if the batch
521+
/// reached the effective ceiling.
522+
/// - `Some(Err(_))` — an error occurred. Notable cases:
523+
/// - `BackfillContinuityBreak` if the cursor has drifted past
524+
/// `tip + 1` (provider tip regressed, e.g. reorg or lagging
525+
/// replica behind a load balancer) — silently switching to
526+
/// frontfill from this state would emit a segment whose parent
527+
/// isn't in the DB.
528+
/// - `BackfillContinuityBreak` if the first fetched block's parent
529+
/// hash doesn't match the last emitted block.
530+
/// - Any provider/RPC error from `get_block_number` or
531+
/// `fetch_range`.
510532
#[tracing::instrument(
511533
level = "info",
512534
skip_all,
@@ -534,14 +556,34 @@ where
534556
}
535557
};
536558

537-
let backfill_ceiling = tip.saturating_sub(self.buffer_capacity as u64 / 2);
538-
if from > backfill_ceiling {
539-
self.backfill_from = None;
540-
info!("backfill complete, switching to frontfill");
541-
return None;
559+
// `from == tip + 1` is the normal completion state (last batch
560+
// ended at `to == tip`). `from > tip + 1` means the provider's
561+
// tip regressed below our cursor (reorg, lagging replica) —
562+
// silently switching to frontfill from there would emit a
563+
// segment whose parent isn't in the DB.
564+
match from.cmp(&tip.saturating_add(1)) {
565+
Ordering::Equal => {
566+
self.backfill_from = None;
567+
info!("backfill complete, switching to frontfill");
568+
return None;
569+
}
570+
Ordering::Greater => {
571+
warn!(from, tip, "backfill cursor ahead of tip; provider tip regressed");
572+
return Some(Err(RpcHostError::BackfillContinuityBreak));
573+
}
574+
Ordering::Less => {}
542575
}
543576

544-
let to = backfill_ceiling.min(from + self.backfill_batch_size - 1);
577+
// Normally cap each batch to `(tip - buffer_capacity / 2)` so the
578+
// hash-walk frontfill has headroom on new tips. But if `from` is
579+
// already inside that headroom (e.g., after restart with persisted
580+
// head close to tip), we must still ingest up to `tip` — otherwise
581+
// the processor receives a one-block frontfill segment whose parent
582+
// is not yet in the DB.
583+
let backfill_ceiling = tip.saturating_sub(self.buffer_capacity as u64 / 2);
584+
let effective_ceiling = if from > backfill_ceiling { tip } else { backfill_ceiling };
585+
586+
let to = effective_ceiling.min(from + self.backfill_batch_size - 1);
545587
span.record("to", to);
546588

547589
let blocks = match self.fetch_range(from, to).await {
@@ -577,7 +619,7 @@ where
577619
self.last_emitted = Some((n, h));
578620
}
579621

580-
let backfill_done = to >= backfill_ceiling;
622+
let backfill_done = to >= effective_ceiling;
581623
if backfill_done {
582624
self.backfill_from = None;
583625
} else {

0 commit comments

Comments
 (0)