1use crossbeam_channel::{Receiver, Sender, unbounded};
2use dashmap::{DashMap, DashSet};
3use futures_util::future::BoxFuture;
4use reqwest::{Client, Url};
5use solana_address::Address;
6use solana_geyser_plugin_manager::{
7 block_metadata_notifier_interface::BlockMetadataNotifier,
8 geyser_plugin_service::GeyserPluginServiceError,
9};
10use solana_hash::Hash;
11use solana_ledger::entry_notifier_interface::EntryNotifier;
12use solana_reward_info::RewardInfo;
13use solana_rpc::{
14 optimistically_confirmed_bank_tracker::SlotNotification,
15 transaction_notifier_interface::TransactionNotifier,
16};
17use solana_runtime::bank::{KeyedRewardsAndNumPartitions, RewardType};
18use solana_sdk_ids::vote::id as vote_program_id;
19use solana_transaction::versioned::VersionedTransaction;
20use std::{
21 fmt::Display,
22 future::Future,
23 io,
24 ops::Range,
25 path::PathBuf,
26 sync::{
27 Arc,
28 atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
29 },
30};
31use thiserror::Error;
32use tokio::{
33 sync::{
34 broadcast::{self, error::TryRecvError},
35 mpsc, oneshot,
36 },
37 time::{sleep, timeout},
38};
39
40use crate::{
41 LOG_MODULE, SharedError,
42 epochs::{
43 FetchEpochStreamOptions, epoch_to_slot_range, fetch_epoch_stream,
44 fetch_epoch_stream_with_options, slot_to_epoch,
45 },
46 index::{SLOT_OFFSET_INDEX, SlotOffsetIndexError},
47 node_reader::NodeReader,
48 utils,
49};
50
51pub const OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
55const OP_TIMEOUT_SEQUENTIAL: std::time::Duration = std::time::Duration::from_secs(180);
56const RETRY_BACKOFF_BASE: std::time::Duration = std::time::Duration::from_secs(1);
60const RETRY_BACKOFF_MAX: std::time::Duration = std::time::Duration::from_secs(32);
61const BINCODE_EPOCH_CUTOFF: u64 = 157;
63
64fn poll_shutdown(
65 flag: &Arc<std::sync::atomic::AtomicBool>,
66 receiver: &mut Option<broadcast::Receiver<()>>,
67) -> bool {
68 if let Some(rx) = receiver {
69 match rx.try_recv() {
70 Ok(_) | Err(TryRecvError::Lagged(_)) => {
71 flag.store(true, Ordering::SeqCst);
72 }
73 Err(TryRecvError::Closed) => {
74 flag.store(true, Ordering::SeqCst);
75 }
76 Err(TryRecvError::Empty) => {}
77 }
78 }
79 flag.load(Ordering::SeqCst)
80}
81
82fn is_shutdown_error(err: &FirehoseError) -> bool {
83 fn is_interrupted(inner: &(dyn std::error::Error + 'static)) -> bool {
84 inner
85 .downcast_ref::<io::Error>()
86 .map(|io_err| io_err.kind() == io::ErrorKind::Interrupted)
87 .unwrap_or(false)
88 }
89
90 match err {
91 FirehoseError::BlockHandlerError(inner)
92 | FirehoseError::TransactionHandlerError(inner)
93 | FirehoseError::EntryHandlerError(inner)
94 | FirehoseError::RewardHandlerError(inner)
95 | FirehoseError::OnStatsHandlerError(inner) => is_interrupted(inner.as_ref()),
96 _ => false,
97 }
98}
99
100pub mod thread_activity {
103 use dashmap::{DashMap, DashSet};
104 use once_cell::sync::Lazy;
105 use std::sync::atomic::{AtomicU64, Ordering};
106 use std::time::Instant;
107
108 static ORIGIN: Lazy<Instant> = Lazy::new(Instant::now);
109 static LAST_ACTIVITY_MS: Lazy<DashMap<usize, u64, ahash::RandomState>> =
110 Lazy::new(|| DashMap::with_hasher(ahash::RandomState::new()));
111 static FINISHED: Lazy<DashSet<usize, ahash::RandomState>> =
112 Lazy::new(|| DashSet::with_hasher(ahash::RandomState::new()));
113 static TX_COUNTS: Lazy<DashMap<usize, u64, ahash::RandomState>> =
114 Lazy::new(|| DashMap::with_hasher(ahash::RandomState::new()));
115 static STREAM_START_MS: Lazy<DashMap<usize, u64, ahash::RandomState>> =
116 Lazy::new(|| DashMap::with_hasher(ahash::RandomState::new()));
117 static RECYCLE_REQUESTED: Lazy<DashSet<usize, ahash::RandomState>> =
118 Lazy::new(|| DashSet::with_hasher(ahash::RandomState::new()));
119 static RECYCLES: AtomicU64 = AtomicU64::new(0);
120 static TIMEOUTS: AtomicU64 = AtomicU64::new(0);
121 static STEALS: AtomicU64 = AtomicU64::new(0);
122
123 pub fn now_ms() -> u64 {
125 ORIGIN.elapsed().as_millis() as u64
126 }
127
128 pub fn reset() {
130 Lazy::force(&ORIGIN);
131 LAST_ACTIVITY_MS.clear();
132 FINISHED.clear();
133 TX_COUNTS.clear();
134 STREAM_START_MS.clear();
135 RECYCLE_REQUESTED.clear();
136 RECYCLES.store(0, Ordering::Relaxed);
137 TIMEOUTS.store(0, Ordering::Relaxed);
138 STEALS.store(0, Ordering::Relaxed);
139 }
140
141 pub fn note_steal() {
143 STEALS.fetch_add(1, Ordering::Relaxed);
144 }
145
146 pub fn steal_count() -> u64 {
148 STEALS.load(Ordering::Relaxed)
149 }
150
151 pub fn note_recycle() {
153 RECYCLES.fetch_add(1, Ordering::Relaxed);
154 }
155
156 pub fn recycle_count() -> u64 {
158 RECYCLES.load(Ordering::Relaxed)
159 }
160
161 pub fn note_timeout() {
163 TIMEOUTS.fetch_add(1, Ordering::Relaxed);
164 }
165
166 pub fn timeout_count() -> u64 {
168 TIMEOUTS.load(Ordering::Relaxed)
169 }
170
171 pub fn add_transactions(thread_index: usize, count: u64) {
173 *TX_COUNTS.entry(thread_index).or_insert(0) += count;
174 }
175
176 pub fn tx_count(thread_index: usize) -> u64 {
178 TX_COUNTS
179 .get(&thread_index)
180 .map(|count| *count)
181 .unwrap_or(0)
182 }
183
184 pub fn note_stream_start(thread_index: usize) {
186 STREAM_START_MS.insert(thread_index, now_ms());
187 }
188
189 pub fn stream_age_ms(thread_index: usize) -> Option<u64> {
191 STREAM_START_MS
192 .get(&thread_index)
193 .map(|stamp| now_ms().saturating_sub(*stamp))
194 }
195
196 pub fn request_recycle(thread_index: usize) {
198 RECYCLE_REQUESTED.insert(thread_index);
199 }
200
201 pub fn take_recycle(thread_index: usize) -> bool {
203 RECYCLE_REQUESTED.remove(&thread_index).is_some()
204 }
205
206 pub fn note_finished(thread_index: usize) {
209 FINISHED.insert(thread_index);
210 }
211
212 pub fn is_finished(thread_index: usize) -> bool {
214 FINISHED.contains(&thread_index)
215 }
216
217 pub fn clear_finished(thread_index: usize) {
219 FINISHED.remove(&thread_index);
220 }
221
222 pub fn note(thread_index: usize) {
224 LAST_ACTIVITY_MS.insert(thread_index, now_ms());
225 }
226
227 pub fn idle_ms(thread_index: usize) -> Option<u64> {
229 LAST_ACTIVITY_MS
230 .get(&thread_index)
231 .map(|stamp| now_ms().saturating_sub(*stamp))
232 }
233}
234
235const SPAWN_GRACE_DEFAULT: std::time::Duration = std::time::Duration::from_secs(30);
239
240fn recycle_threshold_pct() -> u64 {
244 std::env::var("JETSTREAMER_RECYCLE_PCT")
245 .ok()
246 .and_then(|raw| raw.trim().parse::<u64>().ok())
247 .map(|pct| pct.min(100))
248 .unwrap_or(50)
249}
250
251fn spawn_pending_max() -> usize {
255 std::env::var("JETSTREAMER_SPAWN_PENDING")
256 .ok()
257 .and_then(|raw| raw.trim().parse::<usize>().ok())
258 .filter(|&pending| pending > 0)
259 .unwrap_or(24)
260}
261
262fn spawn_grace_from_env() -> Option<std::time::Duration> {
263 match std::env::var("JETSTREAMER_SPAWN_GRACE_SECS") {
264 Ok(raw) => match raw.trim().parse::<u64>() {
265 Ok(0) => None,
266 Ok(secs) => Some(std::time::Duration::from_secs(secs)),
267 Err(_) => Some(SPAWN_GRACE_DEFAULT),
268 },
269 Err(_) => Some(SPAWN_GRACE_DEFAULT),
270 }
271}
272
273async fn wait_for_green_threads(
287 grace: std::time::Duration,
288 shutdown_flag: &Arc<AtomicBool>,
289 handles: &[tokio::task::JoinHandle<()>],
290) {
291 let green_ms = (OP_TIMEOUT.as_millis() as u64) / 10;
292 let red_ms = OP_TIMEOUT.as_millis() as u64;
293 let pending_max = spawn_pending_max();
294 let spawned = handles.len();
295 let mut deadline = std::time::Instant::now() + grace;
296 let mut logged_red_hold = false;
297 loop {
298 if shutdown_flag.load(Ordering::SeqCst) {
299 return;
300 }
301 let idle_of_running = |(thread, handle): (usize, &tokio::task::JoinHandle<()>)| {
304 if handle.is_finished() {
305 None
306 } else {
307 Some(thread_activity::idle_ms(thread))
308 }
309 };
310 let not_yet_green = handles
311 .iter()
312 .enumerate()
313 .filter_map(idle_of_running)
314 .filter(|idle| !idle.is_some_and(|idle| idle < green_ms))
315 .count();
316 let any_red = handles
317 .iter()
318 .enumerate()
319 .filter_map(idle_of_running)
320 .any(|idle| idle.is_some_and(|idle| idle >= red_ms));
321 if any_red {
324 deadline = std::time::Instant::now() + grace;
327 if !logged_red_hold {
328 logged_red_hold = true;
329 log::info!(
330 target: LOG_MODULE,
331 "holding thread ramp at {} threads while stalled threads recover",
332 spawned
333 );
334 }
335 } else if not_yet_green < pending_max {
336 return;
337 } else if std::time::Instant::now() >= deadline {
338 log::info!(
339 target: LOG_MODULE,
340 "spawn grace elapsed with non-green threads; launching thread {} anyway",
341 spawned
342 );
343 return;
344 }
345 sleep(std::time::Duration::from_millis(15)).await;
346 }
347}
348
349struct WorkSlice {
357 start: AtomicU64,
358 next: AtomicU64,
359 end: AtomicU64,
360}
361
362const MIN_STEAL_SLOTS: u64 = 64;
365
366static ACTIVE_WORK_LEDGER: std::sync::Mutex<Option<Arc<Vec<WorkSlice>>>> =
369 std::sync::Mutex::new(None);
370
371pub fn resume_floor() -> Option<u64> {
376 let ledger = ACTIVE_WORK_LEDGER.lock().unwrap();
377 ledger.as_ref().and_then(|slices| {
378 slices
379 .iter()
380 .filter_map(|slice| {
381 let next = slice.next.load(Ordering::SeqCst);
382 let end = slice.end.load(Ordering::SeqCst);
383 (next < end).then_some(next)
384 })
385 .min()
386 })
387}
388
389struct StealRequest {
395 reply: oneshot::Sender<Option<Range<u64>>>,
396}
397
398fn service_steal_inbox(
404 inbox: &mut mpsc::UnboundedReceiver<StealRequest>,
405 slot_range: &mut Range<u64>,
406 position: u64,
407 slice: &WorkSlice,
408 log_target: &str,
409 allow: bool,
410) {
411 while let Ok(request) = inbox.try_recv() {
412 let remaining = slot_range.end.saturating_sub(position);
413 if !allow || remaining < MIN_STEAL_SLOTS {
414 let _ = request.reply.send(None);
415 continue;
416 }
417 let mid = position + remaining / 2;
418 let granted = mid..slot_range.end;
419 if request.reply.send(Some(granted.clone())).is_ok() {
420 log::info!(
421 target: log_target,
422 "🥷 handed slots {}..{} to a work-stealing thread; continuing to {}",
423 granted.start,
424 granted.end,
425 mid
426 );
427 slot_range.end = mid;
428 slice.end.store(mid, Ordering::SeqCst);
429 }
430 }
431}
432
433async fn request_steal(
447 registry: &[WorkSlice],
448 inboxes: &[mpsc::UnboundedSender<StealRequest>],
449 own_inbox: &mut mpsc::UnboundedReceiver<StealRequest>,
450 thief: usize,
451 lock: &tokio::sync::Mutex<()>,
452) -> Option<(usize, Range<u64>)> {
453 let mut candidates: Vec<(f64, std::cmp::Reverse<u64>, usize)> = {
454 let _guard = lock.lock().await;
455 registry
456 .iter()
457 .enumerate()
458 .filter(|&(index, _)| {
459 index != thief
460 && !thread_activity::is_finished(index)
461 && thread_activity::stream_age_ms(index).is_some()
463 })
464 .filter_map(|(index, slice)| {
465 let start = slice.start.load(Ordering::SeqCst);
466 let next = slice.next.load(Ordering::SeqCst);
467 let end = slice.end.load(Ordering::SeqCst);
468 let remaining = end.saturating_sub(next);
469 if remaining < MIN_STEAL_SLOTS {
470 return None;
471 }
472 let assigned = end.saturating_sub(start).max(1);
473 let fraction = next.saturating_sub(start) as f64 / assigned as f64;
474 Some((fraction, std::cmp::Reverse(remaining), index))
475 })
476 .collect()
477 };
478 candidates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
479 for (_, _, victim) in candidates {
480 let (reply_tx, reply_rx) = oneshot::channel();
481 if inboxes[victim]
482 .send(StealRequest { reply: reply_tx })
483 .is_err()
484 {
485 continue;
486 }
487 let mut reply_rx = reply_rx;
489 let outcome = loop {
490 tokio::select! {
491 reply = &mut reply_rx => break reply,
492 incoming = own_inbox.recv() => {
493 match incoming {
494 Some(request) => {
496 let _ = request.reply.send(None);
497 }
498 None => break (&mut reply_rx).await,
500 }
501 }
502 }
503 };
504 match outcome {
505 Ok(Some(stolen)) => {
506 registry[thief].start.store(stolen.start, Ordering::SeqCst);
507 registry[thief].next.store(stolen.start, Ordering::SeqCst);
508 registry[thief].end.store(stolen.end, Ordering::SeqCst);
509 return Some((victim, stolen));
510 }
511 Ok(None) | Err(_) => continue,
512 }
513 }
514 None
515}
516
517fn reverse_resume_after_error(
529 slot: u64,
530 last_counted_slot: u64,
531 highest_remaining_epoch: Option<u64>,
532) -> (Option<u64>, Option<u64>) {
533 let resume_slot = if slot <= last_counted_slot {
534 last_counted_slot.saturating_add(1)
535 } else {
536 slot
537 };
538 let last_epoch = slot_to_epoch(last_counted_slot);
539 let error_epoch = slot_to_epoch(slot);
540 if error_epoch >= last_epoch && slot_to_epoch(resume_slot) > last_epoch {
541 let highest = if highest_remaining_epoch == Some(last_epoch) {
545 last_epoch.checked_sub(1)
549 } else {
550 highest_remaining_epoch
551 };
552 (None, highest)
553 } else {
554 (Some(resume_slot), highest_remaining_epoch)
555 }
556}
557
558struct RetryBackoff {
562 last_slot: Option<u64>,
563 consecutive: u32,
564}
565
566impl RetryBackoff {
567 const fn new() -> Self {
568 Self {
569 last_slot: None,
570 consecutive: 0,
571 }
572 }
573
574 fn next_delay(&mut self, slot: u64) -> std::time::Duration {
575 if self.last_slot == Some(slot) {
576 self.consecutive = self.consecutive.saturating_add(1);
577 } else {
578 self.last_slot = Some(slot);
579 self.consecutive = 0;
580 }
581 RETRY_BACKOFF_BASE
582 .saturating_mul(1u32 << self.consecutive.min(5))
583 .min(RETRY_BACKOFF_MAX)
584 }
585}
586
587#[derive(Debug, Error)]
590pub enum FirehoseError {
591 Reqwest(reqwest::Error),
593 ReadHeader(SharedError),
595 GeyserPluginService(GeyserPluginServiceError),
597 FailedToGetTransactionNotifier,
599 ReadUntilBlockError(SharedError),
601 GetBlockError(SharedError),
603 NodeDecodingError(usize, SharedError),
605 SlotOffsetIndexError(SlotOffsetIndexError),
607 SeekToSlotError(SharedError),
609 OnLoadError(SharedError),
611 OnStatsHandlerError(SharedError),
613 OperationTimeout(&'static str),
615 ConnectionRecycled,
618 RangeComplete,
621 PrematureStreamEnd,
626 TransactionHandlerError(SharedError),
628 EntryHandlerError(SharedError),
630 RewardHandlerError(SharedError),
632 BlockHandlerError(SharedError),
634}
635
636unsafe impl Send for FirehoseError {}
637unsafe impl Sync for FirehoseError {}
638
639impl Display for FirehoseError {
640 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
641 match self {
642 FirehoseError::Reqwest(e) => write!(f, "Reqwest error: {}", e),
643 FirehoseError::ReadHeader(error) => {
644 write!(f, "Error reading header: {}", error)
645 }
646 FirehoseError::GeyserPluginService(geyser_plugin_service_error) => write!(
647 f,
648 "Error initializing geyser plugin service: {}",
649 geyser_plugin_service_error
650 ),
651 FirehoseError::FailedToGetTransactionNotifier => write!(
652 f,
653 "Failed to get transaction notifier from GeyserPluginService"
654 ),
655 FirehoseError::ReadUntilBlockError(error) => {
656 write!(f, "Error reading until block: {}", error)
657 }
658 FirehoseError::GetBlockError(error) => write!(f, "Error getting block: {}", error),
659 FirehoseError::NodeDecodingError(item_index, error) => {
660 write!(
661 f,
662 "Error seeking, reading data from, or decoding data for data node {}: {}",
663 item_index, error
664 )
665 }
666 FirehoseError::SlotOffsetIndexError(slot_offset_index_error) => write!(
667 f,
668 "Error getting info from slot offset index: {}",
669 slot_offset_index_error
670 ),
671 FirehoseError::SeekToSlotError(error) => {
672 write!(f, "Error seeking to slot: {}", error)
673 }
674 FirehoseError::OnLoadError(error) => write!(f, "Error on load: {}", error),
675 FirehoseError::OnStatsHandlerError(error) => {
676 write!(f, "Stats handler error: {}", error)
677 }
678 FirehoseError::OperationTimeout(op) => {
679 write!(f, "Timeout while waiting for operation: {}", op)
680 }
681 FirehoseError::ConnectionRecycled => {
682 write!(f, "connection recycled to refresh throughput")
683 }
684 FirehoseError::RangeComplete => {
685 write!(f, "slot range complete")
686 }
687 FirehoseError::PrematureStreamEnd => {
688 write!(
689 f,
690 "stream ended before the slot range was fully processed (connection closed mid-transfer)"
691 )
692 }
693 FirehoseError::TransactionHandlerError(error) => {
694 write!(f, "Transaction handler error: {}", error)
695 }
696 FirehoseError::EntryHandlerError(error) => {
697 write!(f, "Entry handler error: {}", error)
698 }
699 FirehoseError::RewardHandlerError(error) => {
700 write!(f, "Reward handler error: {}", error)
701 }
702 FirehoseError::BlockHandlerError(error) => {
703 write!(f, "Block handler error: {}", error)
704 }
705 }
706 }
707}
708
709impl From<reqwest::Error> for FirehoseError {
710 fn from(e: reqwest::Error) -> Self {
711 FirehoseError::Reqwest(e)
712 }
713}
714
715impl From<GeyserPluginServiceError> for FirehoseError {
716 fn from(e: GeyserPluginServiceError) -> Self {
717 FirehoseError::GeyserPluginService(e)
718 }
719}
720
721impl From<SlotOffsetIndexError> for FirehoseError {
722 fn from(e: SlotOffsetIndexError) -> Self {
723 FirehoseError::SlotOffsetIndexError(e)
724 }
725}
726
727#[derive(Clone, PartialEq, Eq, Hash, Debug)]
729pub struct ThreadStats {
730 pub thread_id: usize,
732 pub start_time: std::time::Instant,
734 pub finish_time: Option<std::time::Instant>,
736 pub slot_range: Range<u64>,
738 pub initial_slot_range: Range<u64>,
740 pub current_slot: u64,
742 pub slots_processed: u64,
744 pub blocks_processed: u64,
746 pub leader_skipped_slots: u64,
748 pub transactions_processed: u64,
750 pub entries_processed: u64,
752 pub rewards_processed: u64,
754}
755
756#[derive(Clone, PartialEq, Eq, Hash, Debug)]
758pub struct Stats {
759 pub thread_stats: ThreadStats,
761 pub start_time: std::time::Instant,
763 pub finish_time: Option<std::time::Instant>,
765 pub slot_range: Range<u64>,
767 pub slots_processed: u64,
769 pub blocks_processed: u64,
771 pub leader_skipped_slots: u64,
773 pub transactions_processed: u64,
775 pub entries_processed: u64,
777 pub rewards_processed: u64,
779 pub transactions_since_last_pulse: u64,
781 pub blocks_since_last_pulse: u64,
783 pub slots_since_last_pulse: u64,
785 pub time_since_last_pulse: std::time::Duration,
787}
788
789#[derive(Clone, PartialEq, Eq, Hash, Debug)]
791pub struct StatsTracking<OnStats: Handler<Stats>> {
792 pub on_stats: OnStats,
794 pub tracking_interval_slots: u64,
796}
797
798#[inline(always)]
799#[allow(clippy::too_many_arguments)]
800async fn maybe_emit_stats<OnStats: Handler<Stats>>(
801 stats_tracking: Option<&StatsTracking<OnStats>>,
802 thread_index: usize,
803 thread_stats: &ThreadStats,
804 overall_slots_processed: &AtomicU64,
805 overall_blocks_processed: &AtomicU64,
806 overall_transactions_processed: &AtomicU64,
807 overall_entries_processed: &AtomicU64,
808 transactions_since_stats: &AtomicU64,
809 blocks_since_stats: &AtomicU64,
810 slots_since_stats: &AtomicU64,
811 last_pulse: &Arc<AtomicU64>,
812 base_instant: std::time::Instant,
813) -> Result<(), (FirehoseError, u64)> {
814 if let Some(stats_tracker) = stats_tracking {
815 let total_slots = overall_slots_processed.load(Ordering::Relaxed);
816 let total_blocks = overall_blocks_processed.load(Ordering::Relaxed);
817 let total_transactions = overall_transactions_processed.load(Ordering::Relaxed);
818 let total_entries = overall_entries_processed.load(Ordering::Relaxed);
819 let now_nanos = base_instant.elapsed().as_nanos() as u64;
820 let previous = last_pulse.swap(now_nanos, Ordering::Relaxed);
821 let delta_nanos = now_nanos.saturating_sub(previous);
822 let time_since_last_pulse = std::time::Duration::from_nanos(delta_nanos.max(1));
823 let processed_transactions = transactions_since_stats.swap(0, Ordering::Relaxed);
824 let processed_blocks = blocks_since_stats.swap(0, Ordering::Relaxed);
825 let processed_slots = slots_since_stats.swap(0, Ordering::Relaxed);
826
827 let stats = Stats {
828 thread_stats: thread_stats.clone(),
829 start_time: thread_stats.start_time,
830 finish_time: thread_stats.finish_time,
831 slot_range: thread_stats.slot_range.clone(),
832 slots_processed: total_slots,
833 blocks_processed: total_blocks,
834 leader_skipped_slots: total_slots.saturating_sub(total_blocks),
835 transactions_processed: total_transactions,
836 entries_processed: total_entries,
837 rewards_processed: thread_stats.rewards_processed,
838 transactions_since_last_pulse: processed_transactions,
839 blocks_since_last_pulse: processed_blocks,
840 slots_since_last_pulse: processed_slots,
841 time_since_last_pulse,
842 };
843
844 if let Err(e) = (stats_tracker.on_stats)(thread_index, stats).await {
845 last_pulse.store(previous, Ordering::Relaxed);
846 transactions_since_stats.fetch_add(processed_transactions, Ordering::Relaxed);
847 blocks_since_stats.fetch_add(processed_blocks, Ordering::Relaxed);
848 slots_since_stats.fetch_add(processed_slots, Ordering::Relaxed);
849 return Err((
850 FirehoseError::OnStatsHandlerError(e),
851 thread_stats.current_slot,
852 ));
853 }
854 }
855 Ok(())
856}
857
858#[inline(always)]
859fn fetch_add_if(tracking_enabled: bool, atomic: &AtomicU64, value: u64) {
860 if tracking_enabled {
861 atomic.fetch_add(value, Ordering::Relaxed);
862 }
863}
864
865fn clear_pending_skip(
866 map: &DashMap<usize, DashSet<u64, ahash::RandomState>, ahash::RandomState>,
867 thread_id: usize,
868 slot: u64,
869) -> bool {
870 map.get(&thread_id)
871 .map(|set| set.remove(&slot).is_some())
872 .unwrap_or(false)
873}
874
875fn decode_transaction_status_meta_from_frame(
876 slot: u64,
877 reassembled_metadata: Vec<u8>,
878) -> Result<solana_transaction_status::TransactionStatusMeta, SharedError> {
879 if reassembled_metadata.is_empty() {
880 return Ok(solana_transaction_status::TransactionStatusMeta::default());
882 }
883
884 match utils::decompress_zstd(reassembled_metadata.as_slice()) {
885 Ok(decompressed) => {
886 decode_transaction_status_meta(slot, decompressed.as_slice()).map_err(|err| {
887 Box::new(std::io::Error::other(format!(
888 "decode transaction metadata (slot {slot}): {err}"
889 ))) as SharedError
890 })
891 }
892 Err(decomp_err) => {
893 decode_transaction_status_meta(slot, reassembled_metadata.as_slice()).map_err(|err| {
896 Box::new(std::io::Error::other(format!(
897 "transaction metadata not zstd-compressed for slot {slot}; raw decode failed (raw_err={err}, decompress_err={decomp_err})"
898 ))) as SharedError
899 })
900 }
901 }
902}
903
904#[derive(Debug, Default)]
905struct DecodedRewards {
906 keyed_rewards: Vec<(Address, RewardInfo)>,
907 num_partitions: Option<u64>,
908}
909
910impl DecodedRewards {
911 fn empty() -> Self {
912 Self {
913 keyed_rewards: Vec::new(),
914 num_partitions: None,
915 }
916 }
917}
918
919fn decode_rewards_from_frame(
920 slot: u64,
921 reassembled_rewards: Vec<u8>,
922) -> Result<DecodedRewards, SharedError> {
923 if reassembled_rewards.is_empty() {
924 return Ok(DecodedRewards::empty());
926 }
927
928 match utils::decompress_zstd(reassembled_rewards.as_slice()) {
929 Ok(decompressed) => decode_rewards_from_bytes(slot, decompressed.as_slice()).map_err(
930 |err| {
931 Box::new(std::io::Error::other(format!(
932 "decode rewards (slot {slot}): {err}"
933 ))) as SharedError
934 },
935 ),
936 Err(decomp_err) => decode_rewards_from_bytes(slot, reassembled_rewards.as_slice()).map_err(
937 |err| {
938 Box::new(std::io::Error::other(format!(
939 "rewards not zstd-compressed for slot {slot}; raw decode failed (raw_err={err}, decompress_err={decomp_err})"
940 ))) as SharedError
941 },
942 ),
943 }
944}
945
946fn decode_rewards_from_bytes(slot: u64, bytes: &[u8]) -> Result<DecodedRewards, SharedError> {
947 let epoch = slot_to_epoch(slot);
948 let proto_attempt: Result<solana_storage_proto::convert::generated::Rewards, _> =
949 prost_011::Message::decode(bytes);
950 match proto_attempt {
951 Ok(proto) => {
952 let num_partitions = proto.num_partitions.as_ref().map(|p| p.num_partitions);
953 let keyed_rewards = convert_proto_rewards(&proto).map_err(|err| {
954 Box::new(std::io::Error::other(format!(
955 "convert rewards proto failed (epoch {epoch}): {err}"
956 ))) as SharedError
957 })?;
958 Ok(DecodedRewards {
959 keyed_rewards,
960 num_partitions,
961 })
962 }
963 Err(proto_err) => {
964 let stored: solana_storage_proto::StoredExtendedRewards =
965 bincode::deserialize(bytes).map_err(|bin_err| {
966 Box::new(std::io::Error::other(format!(
967 "protobuf decode rewards failed (epoch {epoch}); bincode failed too: {bin_err}; protobuf error: {proto_err}"
968 ))) as SharedError
969 })?;
970 let proto: solana_storage_proto::convert::generated::Rewards = stored.into();
971 let num_partitions = proto.num_partitions.as_ref().map(|p| p.num_partitions);
972 let keyed_rewards = convert_proto_rewards(&proto).map_err(|err| {
973 Box::new(std::io::Error::other(format!(
974 "convert rewards bincode fallback failed (epoch {epoch}); protobuf error: {proto_err}; conversion error: {err}"
975 ))) as SharedError
976 })?;
977 Ok(DecodedRewards {
978 keyed_rewards,
979 num_partitions,
980 })
981 }
982 }
983}
984
985fn decode_transaction_status_meta(
986 slot: u64,
987 metadata_bytes: &[u8],
988) -> Result<solana_transaction_status::TransactionStatusMeta, SharedError> {
989 let epoch = slot_to_epoch(slot);
990 let mut bincode_err: Option<String> = None;
991 if epoch < BINCODE_EPOCH_CUTOFF {
992 match bincode::deserialize::<solana_storage_proto::StoredTransactionStatusMeta>(
993 metadata_bytes,
994 ) {
995 Ok(stored) => return Ok(stored.into()),
996 Err(err) => {
997 bincode_err = Some(err.to_string());
998 }
999 }
1000 }
1001
1002 let bin_err_for_proto = bincode_err.clone();
1003 let proto: solana_storage_proto::convert::generated::TransactionStatusMeta =
1004 prost_011::Message::decode(metadata_bytes).map_err(|err| {
1005 if let Some(ref bin_err) = bin_err_for_proto {
1007 Box::new(std::io::Error::other(format!(
1008 "protobuf decode transaction metadata failed (epoch {epoch}); bincode failed earlier: {bin_err}; protobuf error: {err}"
1009 ))) as SharedError
1010 } else {
1011 Box::new(std::io::Error::other(format!(
1012 "protobuf decode transaction metadata: {err}"
1013 ))) as SharedError
1014 }
1015 })?;
1016
1017 proto.try_into().map_err(|err| {
1018 if let Some(ref bin_err) = bincode_err {
1019 Box::new(std::io::Error::other(format!(
1020 "convert transaction metadata proto failed (epoch {epoch}); bincode failed earlier: {bin_err}; conversion error: {err}"
1021 ))) as SharedError
1022 } else {
1023 Box::new(std::io::Error::other(format!(
1024 "convert transaction metadata proto: {err}"
1025 ))) as SharedError
1026 }
1027 })
1028}
1029
1030#[cfg(test)]
1031mod metadata_decode_tests {
1032 use super::{decode_transaction_status_meta, decode_transaction_status_meta_from_frame};
1033 use solana_message::v0::LoadedAddresses;
1034 use solana_storage_proto::StoredTransactionStatusMeta;
1035 use solana_transaction_status::TransactionStatusMeta;
1036
1037 fn sample_meta() -> TransactionStatusMeta {
1038 TransactionStatusMeta {
1039 fee: 42,
1040 pre_balances: vec![1, 2],
1041 post_balances: vec![3, 4],
1042 log_messages: Some(vec!["hello".into()]),
1043 pre_token_balances: Some(Vec::new()),
1044 post_token_balances: Some(Vec::new()),
1045 rewards: Some(Vec::new()),
1046 compute_units_consumed: Some(7),
1047 cost_units: Some(9),
1048 loaded_addresses: LoadedAddresses::default(),
1049 ..TransactionStatusMeta::default()
1050 }
1051 }
1052
1053 #[test]
1054 fn decodes_bincode_metadata_for_early_epochs() {
1055 let stored = StoredTransactionStatusMeta {
1056 status: Ok(()),
1057 fee: 42,
1058 pre_balances: vec![1, 2],
1059 post_balances: vec![3, 4],
1060 inner_instructions: None,
1061 log_messages: Some(vec!["hello".into()]),
1062 pre_token_balances: Some(Vec::new()),
1063 post_token_balances: Some(Vec::new()),
1064 rewards: Some(Vec::new()),
1065 return_data: None,
1066 compute_units_consumed: Some(7),
1067 cost_units: Some(9),
1068 };
1069 let bytes = bincode::serialize(&stored).expect("bincode serialize");
1070 let decoded = decode_transaction_status_meta(0, &bytes).expect("decode");
1071 assert_eq!(decoded, TransactionStatusMeta::from(stored));
1072 }
1073
1074 #[test]
1075 fn decodes_protobuf_metadata_for_later_epochs() {
1076 let meta = sample_meta();
1077 let generated: solana_storage_proto::convert::generated::TransactionStatusMeta =
1078 meta.clone().into();
1079 let bytes = prost_011::Message::encode_to_vec(&generated);
1080 let decoded = decode_transaction_status_meta(157 * 432000, &bytes).expect("decode");
1081 assert_eq!(decoded, meta);
1082 }
1083
1084 #[test]
1085 fn falls_back_to_proto_when_early_epoch_bytes_are_proto() {
1086 let meta = sample_meta();
1087 let generated: solana_storage_proto::convert::generated::TransactionStatusMeta =
1088 meta.clone().into();
1089 let bytes = prost_011::Message::encode_to_vec(&generated);
1090 let decoded = decode_transaction_status_meta(100 * 432000, &bytes).expect("decode");
1092 assert_eq!(decoded, meta);
1093 }
1094
1095 #[test]
1096 fn empty_frame_decodes_to_default() {
1097 let decoded = decode_transaction_status_meta_from_frame(0, Vec::new()).expect("decode");
1098 assert_eq!(decoded, TransactionStatusMeta::default());
1099 }
1100
1101 #[test]
1102 fn raw_bincode_frame_without_zstd_still_decodes() {
1103 let stored = StoredTransactionStatusMeta {
1104 status: Ok(()),
1105 fee: 1,
1106 pre_balances: vec![],
1107 post_balances: vec![],
1108 inner_instructions: None,
1109 log_messages: None,
1110 pre_token_balances: Some(Vec::new()),
1111 post_token_balances: Some(Vec::new()),
1112 rewards: Some(Vec::new()),
1113 return_data: None,
1114 compute_units_consumed: None,
1115 cost_units: None,
1116 };
1117 let raw_bytes = bincode::serialize(&stored).expect("serialize");
1118 let decoded =
1119 decode_transaction_status_meta_from_frame(0, raw_bytes).expect("decode fallback");
1120 assert_eq!(decoded, TransactionStatusMeta::from(stored));
1121 }
1122}
1123
1124#[cfg(test)]
1125mod rewards_decode_tests {
1126 use super::decode_rewards_from_bytes;
1127 use solana_sdk_ids::vote::id as vote_program_id;
1128 use solana_storage_proto::StoredExtendedRewards;
1129 use solana_transaction_status::{Reward, RewardType};
1130
1131 #[test]
1132 fn decodes_protobuf_rewards() {
1133 let pubkey = vote_program_id().to_string();
1134 let proto = solana_storage_proto::convert::generated::Rewards {
1135 rewards: vec![solana_storage_proto::convert::generated::Reward {
1136 pubkey,
1137 lamports: 5,
1138 post_balance: 10,
1139 reward_type: solana_storage_proto::convert::generated::RewardType::Fee as i32,
1140 commission: "1".to_string(),
1141 }],
1142 num_partitions: Some(solana_storage_proto::convert::generated::NumPartitions {
1143 num_partitions: 2,
1144 }),
1145 };
1146 let bytes = prost_011::Message::encode_to_vec(&proto);
1147 let decoded = decode_rewards_from_bytes(0, &bytes).expect("decode proto rewards");
1148 assert_eq!(decoded.keyed_rewards.len(), 1);
1149 assert_eq!(decoded.num_partitions, Some(2));
1150 }
1151
1152 #[test]
1153 fn decodes_bincode_rewards() {
1154 let pubkey = vote_program_id().to_string();
1155 let reward = Reward {
1156 pubkey,
1157 lamports: 7,
1158 post_balance: 9,
1159 reward_type: Some(RewardType::Rent),
1160 commission: Some(3),
1161 };
1162 let stored_rewards: StoredExtendedRewards = vec![reward.into()];
1163 let bytes = bincode::serialize(&stored_rewards).expect("bincode serialize");
1164 let decoded = decode_rewards_from_bytes(0, &bytes).expect("decode bincode rewards");
1165 assert_eq!(decoded.keyed_rewards.len(), 1);
1166 assert_eq!(decoded.num_partitions, None);
1167 }
1168}
1169
1170#[derive(Debug, Clone)]
1172pub struct TransactionData {
1173 pub slot: u64,
1175 pub transaction_slot_index: usize,
1177 pub signature: solana_signature::Signature,
1179 pub message_hash: Hash,
1181 pub is_vote: bool,
1183 pub transaction_status_meta: solana_transaction_status::TransactionStatusMeta,
1185 pub transaction: VersionedTransaction,
1187}
1188
1189#[derive(Debug, Clone)]
1191pub struct EntryData {
1192 pub slot: u64,
1194 pub entry_index: usize,
1196 pub transaction_indexes: Range<usize>,
1198 pub num_hashes: u64,
1200 pub hash: Hash,
1202}
1203
1204#[derive(Debug, Clone)]
1206pub struct RewardsData {
1207 pub slot: u64,
1209 pub rewards: Vec<(Address, RewardInfo)>,
1211}
1212
1213#[derive(Debug)]
1215pub enum BlockData {
1216 Block {
1218 parent_slot: u64,
1220 parent_blockhash: Hash,
1222 slot: u64,
1224 blockhash: Hash,
1226 rewards: KeyedRewardsAndNumPartitions,
1228 block_time: Option<i64>,
1230 block_height: Option<u64>,
1232 executed_transaction_count: u64,
1234 entry_count: u64,
1236 },
1237 PossibleLeaderSkipped {
1240 slot: u64,
1242 },
1243}
1244
1245impl BlockData {
1246 #[inline(always)]
1248 pub const fn slot(&self) -> u64 {
1249 match self {
1250 BlockData::Block { slot, .. } => *slot,
1251 BlockData::PossibleLeaderSkipped { slot } => *slot,
1252 }
1253 }
1254
1255 #[inline(always)]
1257 pub const fn was_skipped(&self) -> bool {
1258 matches!(self, BlockData::PossibleLeaderSkipped { .. })
1259 }
1260
1261 #[inline(always)]
1263 pub const fn block_time(&self) -> Option<i64> {
1264 match self {
1265 BlockData::Block { block_time, .. } => *block_time,
1266 BlockData::PossibleLeaderSkipped { .. } => None,
1267 }
1268 }
1269}
1270
1271type HandlerResult = Result<(), SharedError>;
1272type HandlerFuture = BoxFuture<'static, HandlerResult>;
1273
1274pub trait Handler<Data>: Fn(usize, Data) -> HandlerFuture + Send + Sync + Clone + 'static {}
1276
1277impl<Data, F> Handler<Data> for F where
1278 F: Fn(usize, Data) -> HandlerFuture + Send + Sync + Clone + 'static
1279{
1280}
1281
1282pub type HandlerFn<Data> = fn(usize, Data) -> HandlerFuture;
1284pub type OnBlockFn = HandlerFn<BlockData>;
1286pub type OnTxFn = HandlerFn<TransactionData>;
1288pub type OnEntryFn = HandlerFn<EntryData>;
1290pub type OnRewardFn = HandlerFn<RewardsData>;
1292pub type StatsTracker = StatsTracking<HandlerFn<Stats>>;
1294pub type OnErrorFn = HandlerFn<FirehoseErrorContext>;
1296pub type OnStatsTrackingFn = StatsTracking<HandlerFn<Stats>>;
1298
1299#[derive(Clone, Debug)]
1301pub struct FirehoseErrorContext {
1302 pub thread_id: usize,
1304 pub slot: u64,
1306 pub epoch: u64,
1308 pub error_message: String,
1310}
1311
1312#[inline]
1328#[allow(clippy::too_many_arguments)]
1329pub async fn firehose<OnBlock, OnTransaction, OnEntry, OnRewards, OnStats, OnError>(
1330 threads: u64,
1331 sequential: bool,
1332 reverse: bool,
1333 buffer_window_bytes: Option<u64>,
1334 slot_range: Range<u64>,
1335 on_block: Option<OnBlock>,
1336 on_tx: Option<OnTransaction>,
1337 on_entry: Option<OnEntry>,
1338 on_rewards: Option<OnRewards>,
1339 on_error: Option<OnError>,
1340 stats_tracking: Option<StatsTracking<OnStats>>,
1341 shutdown_signal: Option<broadcast::Receiver<()>>,
1342) -> Result<(), (FirehoseError, u64)>
1343where
1344 OnBlock: Handler<BlockData>,
1345 OnTransaction: Handler<TransactionData>,
1346 OnEntry: Handler<EntryData>,
1347 OnRewards: Handler<RewardsData>,
1348 OnStats: Handler<Stats>,
1349 OnError: Handler<FirehoseErrorContext>,
1350{
1351 if threads == 0 {
1352 return Err((
1353 FirehoseError::OnLoadError("Number of threads must be greater than 0".into()),
1354 slot_range.start,
1355 ));
1356 }
1357 let client = crate::network::create_http_client();
1358 log::info!(target: LOG_MODULE, "starting firehose...");
1359 log::info!(target: LOG_MODULE, "index base url: {}", SLOT_OFFSET_INDEX.base_url());
1360 let sequential = sequential || reverse;
1363 let firehose_threads = if sequential { 1 } else { threads };
1364 let sequential_download_threads = std::cmp::max(1, threads as usize);
1365 let sequential_buffer_window_bytes = buffer_window_bytes
1366 .filter(|value| *value >= 2)
1367 .unwrap_or_else(crate::system::default_firehose_buffer_window_bytes);
1368 if sequential {
1369 log::info!(
1370 target: LOG_MODULE,
1371 "sequential mode enabled: firehose_threads=1, ripget_threads={}, ripget_window={}",
1372 sequential_download_threads,
1373 crate::system::format_byte_size(sequential_buffer_window_bytes)
1374 );
1375 }
1376 let reverse_mode = reverse;
1377 if reverse_mode {
1378 log::info!(
1379 target: LOG_MODULE,
1380 "reverse mode enabled: epochs processed from highest to lowest"
1381 );
1382 }
1383
1384 let slot_range = Arc::new(slot_range);
1385
1386 let subranges = generate_subranges(&slot_range, firehose_threads);
1388 if firehose_threads > 1 {
1389 log::debug!(target: LOG_MODULE, "⚡ thread sub-ranges: {:?}", subranges);
1390 }
1391
1392 let firehose_start = std::time::Instant::now();
1393 let shutdown_flag = Arc::new(AtomicBool::new(false));
1394 if let Some(ref rx) = shutdown_signal {
1395 let mut rx = rx.resubscribe();
1396 let flag = shutdown_flag.clone();
1397 tokio::spawn(async move {
1398 if rx.recv().await.is_ok() {
1399 log::info!(target: LOG_MODULE, "shutdown signal received; notifying firehose threads");
1400 flag.store(true, Ordering::SeqCst);
1401 }
1402 });
1403 }
1404
1405 let shared_ripget_client: Option<ripget::Client> = if sequential {
1407 Some(
1408 ripget::build_client(Some(&format!(
1409 "jetstreamer-firehose/{}",
1410 env!("CARGO_PKG_VERSION")
1411 )))
1412 .expect("failed to build ripget HTTP client"),
1413 )
1414 } else {
1415 None
1416 };
1417
1418 let mut handles = Vec::new();
1419 let error_counts: Arc<Vec<AtomicU32>> =
1421 Arc::new((0..subranges.len()).map(|_| AtomicU32::new(0)).collect());
1422
1423 let overall_slots_processed: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1424 let overall_blocks_processed: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1425 let overall_transactions_processed: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1426 let overall_entries_processed: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1427 let pending_skipped_slots: Arc<
1428 DashMap<usize, DashSet<u64, ahash::RandomState>, ahash::RandomState>,
1429 > = Arc::new(DashMap::with_hasher(ahash::RandomState::new()));
1430
1431 thread_activity::reset();
1432 let spawn_gate = if sequential {
1433 None
1434 } else {
1435 spawn_grace_from_env()
1436 };
1437 let recycle_pct = recycle_threshold_pct();
1442 let thread_total = subranges.len();
1443 let work_registry: Arc<Vec<WorkSlice>> = Arc::new(
1446 subranges
1447 .iter()
1448 .map(|range| WorkSlice {
1449 start: AtomicU64::new(range.start),
1450 next: AtomicU64::new(range.start),
1451 end: AtomicU64::new(range.end),
1452 })
1453 .collect(),
1454 );
1455 let mut steal_inbox_receivers: Vec<Option<mpsc::UnboundedReceiver<StealRequest>>> = Vec::new();
1456 let mut steal_inbox_senders: Vec<mpsc::UnboundedSender<StealRequest>> = Vec::new();
1457 for _ in 0..thread_total {
1458 let (sender, receiver) = mpsc::unbounded_channel();
1459 steal_inbox_senders.push(sender);
1460 steal_inbox_receivers.push(Some(receiver));
1461 }
1462 let steal_inboxes: Arc<Vec<mpsc::UnboundedSender<StealRequest>>> =
1463 Arc::new(steal_inbox_senders);
1464 *ACTIVE_WORK_LEDGER.lock().unwrap() = Some(work_registry.clone());
1465 let coverage_log: Arc<std::sync::Mutex<Vec<(u64, u64)>>> =
1469 Arc::new(std::sync::Mutex::new(Vec::new()));
1470 let overall_start = subranges.first().map(|range| range.start).unwrap_or(0);
1471 let overall_end = subranges.last().map(|range| range.end).unwrap_or(0);
1472 let steal_lock = Arc::new(tokio::sync::Mutex::new(()));
1473 let work_stealing = !sequential && !reverse_mode && thread_total > 1;
1476 if work_stealing {
1477 log::info!(
1478 target: LOG_MODULE,
1479 "work stealing enabled: finished threads adopt half of the least-progressed thread's remaining work"
1480 );
1481 }
1482 let recycle_monitor = (recycle_pct > 0 && !sequential && thread_total > 1).then(|| {
1483 let shutdown_flag = shutdown_flag.clone();
1484 tokio::spawn(async move {
1485 const SWEEP: std::time::Duration = std::time::Duration::from_secs(15);
1486 const MIN_STREAM_AGE_MS: u64 = 30_000;
1487 const MAX_RECYCLES_PER_SWEEP: usize = 16;
1492 let mut prev_counts: Vec<u64> = vec![0; thread_total];
1493 let mut primed = false;
1494 let mut reference: u64 = 0;
1498 loop {
1499 sleep(SWEEP).await;
1500 if shutdown_flag.load(Ordering::SeqCst) {
1501 return;
1502 }
1503 let mut rates = vec![0u64; thread_total];
1504 for (thread, prev) in prev_counts.iter_mut().enumerate() {
1505 let total = thread_activity::tx_count(thread);
1506 rates[thread] = total.saturating_sub(*prev);
1507 *prev = total;
1508 }
1509 if !primed {
1511 primed = true;
1512 continue;
1513 }
1514 let mut moving: Vec<u64> = rates.iter().copied().filter(|&r| r > 0).collect();
1515 if moving.is_empty() {
1516 continue;
1517 }
1518 moving.sort_unstable();
1519 let p90 = moving[(moving.len() - 1) * 9 / 10];
1520 reference = reference.max(p90);
1521 let threshold = reference.saturating_mul(recycle_pct) / 100;
1522 let mut candidates: Vec<(u64, usize)> = rates
1524 .iter()
1525 .copied()
1526 .enumerate()
1527 .filter(|&(thread, rate)| {
1528 !thread_activity::is_finished(thread)
1529 && rate < threshold
1530 && thread_activity::stream_age_ms(thread)
1532 .is_some_and(|age| age >= MIN_STREAM_AGE_MS)
1533 })
1534 .map(|(thread, rate)| (rate, thread))
1535 .collect();
1536 candidates.sort_unstable();
1537 let flagged = candidates.len().min(MAX_RECYCLES_PER_SWEEP);
1538 for &(_, thread) in candidates.iter().take(MAX_RECYCLES_PER_SWEEP) {
1539 thread_activity::request_recycle(thread);
1540 }
1541 if flagged > 0 {
1542 log::info!(
1543 target: LOG_MODULE,
1544 "recycle monitor: rotating {} of {} threads below {}% of the best observed rate",
1545 flagged,
1546 candidates.len(),
1547 recycle_pct
1548 );
1549 }
1550 }
1551 })
1552 });
1553 for (thread_index, mut slot_range) in subranges.into_iter().enumerate() {
1554 if thread_index > 0
1555 && let Some(grace) = spawn_gate
1556 {
1557 wait_for_green_threads(grace, &shutdown_flag, &handles).await;
1558 }
1559 let work_registry = work_registry.clone();
1560 let coverage_log = coverage_log.clone();
1561 let steal_lock = steal_lock.clone();
1562 let steal_inboxes = steal_inboxes.clone();
1563 let mut steal_inbox = steal_inbox_receivers[thread_index]
1564 .take()
1565 .expect("steal inbox taken once per thread");
1566 let error_counts = error_counts.clone();
1567 let client = client.clone();
1568 let on_block = on_block.clone();
1569 let on_tx = on_tx.clone();
1570 let on_entry = on_entry.clone();
1571 let on_reward = on_rewards.clone();
1572 let on_error = on_error.clone();
1573 let overall_slots_processed = overall_slots_processed.clone();
1574 let overall_blocks_processed = overall_blocks_processed.clone();
1575 let overall_transactions_processed = overall_transactions_processed.clone();
1576 let overall_entries_processed = overall_entries_processed.clone();
1577 let stats_tracking = stats_tracking.clone();
1578 let transactions_since_stats = Arc::new(AtomicU64::new(0));
1579 let blocks_since_stats = Arc::new(AtomicU64::new(0));
1580 let slots_since_stats = Arc::new(AtomicU64::new(0));
1581 let last_pulse = Arc::new(AtomicU64::new(0));
1582 let transactions_since_stats_cloned = transactions_since_stats.clone();
1583 let blocks_since_stats_cloned = blocks_since_stats.clone();
1584 let slots_since_stats_cloned = slots_since_stats.clone();
1585 let last_pulse_cloned = last_pulse.clone();
1586 let shutdown_flag = shutdown_flag.clone();
1587 let pending_skipped_slots = pending_skipped_slots.clone();
1588 let thread_shutdown_rx = shutdown_signal.as_ref().map(|rx| rx.resubscribe());
1589 let sequential_mode = sequential;
1590 let reverse_mode_local = reverse_mode;
1591 let ripget_threads = sequential_download_threads;
1592 let ripget_buffer_window_bytes = sequential_buffer_window_bytes;
1593 let ripget_client = shared_ripget_client.clone();
1594
1595 let handle = tokio::spawn(async move {
1596 let transactions_since_stats = transactions_since_stats_cloned;
1597 let blocks_since_stats = blocks_since_stats_cloned;
1598 let slots_since_stats = slots_since_stats_cloned;
1599 let last_pulse = last_pulse_cloned;
1600 let mut shutdown_rx = thread_shutdown_rx;
1601 let start_time = firehose_start;
1602 last_pulse.store(
1603 firehose_start.elapsed().as_nanos() as u64,
1604 Ordering::Relaxed,
1605 );
1606 let log_target = format!("{}::T{:03}", LOG_MODULE, thread_index);
1607 let mut skip_until_index = None;
1608 let last_emitted_slot = slot_range.start.saturating_sub(1);
1609 let block_enabled = on_block.is_some();
1610 let tx_enabled = on_tx.is_some();
1611 let entry_enabled = on_entry.is_some();
1612 let reward_enabled = on_reward.is_some();
1613 let tracking_enabled = stats_tracking.is_some();
1614 if block_enabled {
1615 pending_skipped_slots
1616 .entry(thread_index)
1617 .or_insert_with(|| DashSet::with_hasher(ahash::RandomState::new()));
1618 }
1619 let mut last_counted_slot = slot_range.start.saturating_sub(1);
1620 let mut last_emitted_slot_global = slot_range.start.saturating_sub(1);
1621 let mut reverse_partial_resume: Option<u64> = None;
1625 let mut reverse_highest_remaining_epoch: Option<u64> = if reverse_mode_local {
1626 Some(slot_to_epoch(slot_range.end.saturating_sub(1)))
1627 } else {
1628 None
1629 };
1630 let mut thread_stats = if tracking_enabled {
1631 Some(ThreadStats {
1632 thread_id: thread_index,
1633 start_time,
1634 finish_time: None,
1635 slot_range: slot_range.clone(),
1636 initial_slot_range: slot_range.clone(),
1637 current_slot: slot_range.start,
1638 slots_processed: 0,
1639 blocks_processed: 0,
1640 leader_skipped_slots: 0,
1641 transactions_processed: 0,
1642 entries_processed: 0,
1643 rewards_processed: 0,
1644 })
1645 } else {
1646 None
1647 };
1648
1649 let mut retry_backoff = RetryBackoff::new();
1650 while let Err((err, slot)) = async {
1652 let mut last_emitted_slot = last_emitted_slot_global;
1653 let op_timeout = if sequential_mode {
1654 OP_TIMEOUT_SEQUENTIAL
1655 } else {
1656 OP_TIMEOUT
1657 };
1658 thread_activity::note_stream_start(thread_index);
1661 if work_stealing {
1664 let resume_position = slot_range.start;
1665 service_steal_inbox(
1666 &mut steal_inbox,
1667 &mut slot_range,
1668 resume_position,
1669 &work_registry[thread_index],
1670 &log_target,
1671 true,
1672 );
1673 }
1674 if poll_shutdown(&shutdown_flag, &mut shutdown_rx) {
1675 log::info!(
1676 target: &log_target,
1677 "shutdown requested; terminating firehose thread {}",
1678 thread_index
1679 );
1680 return Ok(());
1681 }
1682 let lowest_epoch = slot_to_epoch(slot_range.start);
1683 let highest_epoch = slot_to_epoch(slot_range.end - 1);
1684 let epoch_range = lowest_epoch..=highest_epoch;
1685 log::info!(
1686 target: &log_target,
1687 "slot range: {} (epoch {}) ... {} (epoch {})",
1688 slot_range.start,
1689 slot_to_epoch(slot_range.start),
1690 slot_range.end,
1691 slot_to_epoch(slot_range.end)
1692 );
1693
1694 log::info!(target: &log_target, "🚒 starting firehose...");
1695
1696 let mut current_slot: Option<u64> = None;
1698 let epoch_iter: Vec<u64> = if reverse_mode_local {
1699 let Some(highest_remaining) = reverse_highest_remaining_epoch else {
1701 return Ok(());
1702 };
1703 if highest_remaining < lowest_epoch {
1704 return Ok(());
1705 }
1706 (lowest_epoch..=highest_remaining).rev().collect()
1707 } else {
1708 epoch_range.clone().collect()
1709 };
1710 for epoch_num in epoch_iter {
1711 if poll_shutdown(&shutdown_flag, &mut shutdown_rx) {
1712 log::info!(
1713 target: &log_target,
1714 "shutdown requested; terminating firehose thread {}",
1715 thread_index
1716 );
1717 return Ok(());
1718 }
1719 log::info!(target: &log_target, "entering epoch {}", epoch_num);
1720 let (epoch_start, epoch_end_inclusive) = epoch_to_slot_range(epoch_num);
1721 let local_start = if reverse_mode_local {
1722 match reverse_partial_resume {
1723 Some(s) if slot_to_epoch(s) == epoch_num => {
1724 std::cmp::max(epoch_start, s)
1725 }
1726 _ => std::cmp::max(slot_range.start, epoch_start),
1727 }
1728 } else {
1729 std::cmp::max(slot_range.start, epoch_start)
1730 };
1731 let local_end_inclusive =
1732 std::cmp::min(slot_range.end.saturating_sub(1), epoch_end_inclusive);
1733 if local_start > local_end_inclusive {
1734 log::debug!(
1735 target: &log_target,
1736 "epoch {} has no overlap with thread range ({}..{}), skipping",
1737 epoch_num,
1738 slot_range.start,
1739 slot_range.end
1740 );
1741 continue;
1742 }
1743 let use_sequential_stream = sequential_mode && local_start == epoch_start;
1744 let stream = match timeout(op_timeout, async {
1745 if use_sequential_stream {
1746 fetch_epoch_stream_with_options(
1747 epoch_num,
1748 &client,
1749 Some(FetchEpochStreamOptions {
1750 sequential: true,
1751 ripget_threads,
1752 buffer_window_bytes: ripget_buffer_window_bytes,
1753 ripget_client: ripget_client.clone(),
1754 }),
1755 )
1756 .await
1757 } else {
1758 fetch_epoch_stream(epoch_num, &client).await
1759 }
1760 })
1761 .await
1762 {
1763 Ok(stream) => stream,
1764 Err(_) => {
1765 return Err((
1766 FirehoseError::OperationTimeout("fetch_epoch_stream"),
1767 current_slot.unwrap_or(slot_range.start),
1768 ));
1769 }
1770 };
1771 let mut reader = NodeReader::new(stream);
1772
1773 let header_fut = reader.read_raw_header();
1774 let header = match timeout(op_timeout, header_fut).await {
1775 Ok(res) => res
1776 .map_err(FirehoseError::ReadHeader)
1777 .map_err(|e| (e, current_slot.unwrap_or(slot_range.start)))?,
1778 Err(_) => {
1779 return Err((
1780 FirehoseError::OperationTimeout("read_raw_header"),
1781 current_slot.unwrap_or(slot_range.start),
1782 ));
1783 }
1784 };
1785 log::debug!(target: &log_target, "read epoch {} header: {:?}", epoch_num, header);
1786
1787 let mut previous_blockhash = Hash::default();
1788 let mut latest_entry_blockhash = Hash::default();
1789 last_counted_slot = local_start.saturating_sub(1);
1792 current_slot = None;
1793 if reverse_mode_local {
1794 last_emitted_slot = local_start.saturating_sub(1);
1798 }
1799 if tracking_enabled
1800 && let Some(ref mut stats) = thread_stats {
1801 stats.current_slot = local_start;
1802 stats.slot_range.start = local_start;
1803 }
1804
1805 if local_start > epoch_start {
1806 reader.prime_seek_permit().await;
1815 let seek_fut = reader.seek_to_slot(local_start);
1816 match timeout(op_timeout, seek_fut).await {
1817 Ok(res) => res.map_err(|e| (e, local_start))?,
1818 Err(_) => {
1819 return Err((
1820 FirehoseError::OperationTimeout("seek_to_slot"),
1821 local_start,
1822 ));
1823 }
1824 }
1825 }
1826
1827 let mut item_index = 0;
1829 let mut displayed_skip_message = false;
1830 loop {
1831 if poll_shutdown(&shutdown_flag, &mut shutdown_rx) {
1832 log::info!(
1833 target: &log_target,
1834 "shutdown requested; terminating firehose thread {}",
1835 thread_index
1836 );
1837 return Ok(());
1838 }
1839 if thread_activity::take_recycle(thread_index) {
1840 log::info!(
1841 target: &log_target,
1842 "recycling connection to refresh throughput"
1843 );
1844 return Err((
1845 FirehoseError::ConnectionRecycled,
1846 current_slot
1847 .map(|slot| slot.saturating_add(1))
1848 .unwrap_or(slot_range.start),
1849 ));
1850 }
1851 let read_fut = reader.read_until_block();
1852 let nodes = match timeout(op_timeout, read_fut).await {
1853 Ok(result) => result
1854 .map_err(FirehoseError::ReadUntilBlockError)
1855 .map_err(|e| {
1856 (
1857 e,
1858 current_slot
1859 .map(|slot| slot.saturating_add(1))
1860 .unwrap_or(slot_range.start),
1861 )
1862 })?,
1863 Err(_) => {
1864 log::warn!(target: &log_target, "timeout reading next block, retrying (will restart)...");
1865 return Err((FirehoseError::OperationTimeout("read_until_block"), current_slot.map(|s| s + 1).unwrap_or(slot_range.start)));
1866 }
1867 };
1868 thread_activity::note(thread_index);
1869 if work_stealing {
1874 service_steal_inbox(
1875 &mut steal_inbox,
1876 &mut slot_range,
1877 last_counted_slot.saturating_add(1),
1878 &work_registry[thread_index],
1879 &log_target,
1880 true,
1881 );
1882 }
1883 let stream_ended = nodes.is_empty()
1884 || nodes
1885 .0
1886 .last()
1887 .is_some_and(|last_node| !last_node.get_node().is_block());
1888 if stream_ended {
1889 let scan_end = local_end_inclusive.min(slot_range.end.saturating_sub(1));
1895 if let Some(missing) =
1896 crate::index::next_present_slot(last_counted_slot, scan_end).await
1897 {
1898 log::warn!(
1899 target: &log_target,
1900 "stream ended prematurely in epoch {} — slot {} (and possibly more) still unprocessed; restarting",
1901 epoch_num,
1902 missing
1903 );
1904 return Err((
1905 FirehoseError::PrematureStreamEnd,
1906 last_counted_slot.saturating_add(1),
1907 ));
1908 }
1909 log::info!(
1910 target: &log_target,
1911 "reached end of epoch {}",
1912 epoch_num
1913 );
1914 break;
1915 }
1916 let block = nodes
1917 .get_block()
1918 .map_err(FirehoseError::GetBlockError)
1919 .map_err(|e| (e, current_slot.unwrap_or(slot_range.start)))?;
1920 log::debug!(
1921 target: &log_target,
1922 "read {} items from epoch {}, now at slot {}",
1923 item_index,
1924 epoch_num,
1925 block.slot
1926 );
1927 let slot = block.slot;
1928 if slot > local_end_inclusive {
1929 log::debug!(
1930 target: &log_target,
1931 "reached end of local slice at slot {} (epoch {}), stopping",
1932 slot,
1933 epoch_num
1934 );
1935 break;
1936 }
1937 if slot >= slot_range.end {
1938 log::info!(target: &log_target, "reached end of slot range at slot {}", slot);
1939 if reverse_mode_local {
1945 break;
1946 }
1947 if block_enabled {
1948 pending_skipped_slots.remove(&thread_index);
1949 }
1950 return Err((FirehoseError::RangeComplete, slot_range.end));
1951 }
1952 debug_assert!(slot < slot_range.end, "processing out-of-range slot {} (end {})", slot, slot_range.end);
1953 if slot < slot_range.start {
1954 if slot.saturating_add(1) == slot_range.start {
1955 log::debug!(
1956 target: &log_target,
1957 "priming reader with preceding slot {}, skipping",
1958 slot
1959 );
1960 } else {
1961 log::warn!(
1962 target: &log_target,
1963 "encountered slot {} before start of range {}, skipping",
1964 slot,
1965 slot_range.start
1966 );
1967 }
1968 continue;
1969 }
1970 current_slot = Some(slot);
1971 let mut entry_index: usize = 0;
1972 let mut this_block_executed_transaction_count: u64 = 0;
1973 let mut this_block_entry_count: u64 = 0;
1974 let mut this_block_rewards = DecodedRewards::empty();
1975
1976 for node_with_cid in &nodes.0 {
1977 item_index += 1;
1978 if let Some(skip) = skip_until_index {
1979 if item_index < skip {
1980 if !displayed_skip_message {
1981 log::info!(
1982 target: &log_target,
1983 "skipping until index {} (at {})",
1984 skip,
1985 item_index
1986 );
1987 displayed_skip_message = true;
1988 }
1989 continue;
1990 } else {
1991 log::info!(
1992 target: &log_target,
1993 "reached target index {}, resuming...",
1994 skip
1995 );
1996 skip_until_index = None;
1997 }
1998 }
1999 let node = node_with_cid.get_node();
2000
2001 if let Some(ref mut stats) = thread_stats {
2002 stats.current_slot = slot;
2003 }
2004
2005 let error_slot = current_slot.unwrap_or(slot_range.start);
2006
2007 use crate::node::Node::*;
2008 match node {
2009 Transaction(tx) => {
2010 if tx_enabled
2011 && let Some(on_tx_cb) = on_tx.as_ref()
2012 {
2013 let error_slot = current_slot.unwrap_or(slot_range.start);
2014 let versioned_tx = tx.as_parsed().map_err(|err| {
2015 (
2016 FirehoseError::NodeDecodingError(item_index, err),
2017 error_slot,
2018 )
2019 })?;
2020 let reassembled_metadata = nodes
2021 .reassemble_dataframes(&tx.metadata)
2022 .map_err(|err| {
2023 (
2024 FirehoseError::NodeDecodingError(item_index, err),
2025 error_slot,
2026 )
2027 })?;
2028
2029 let as_native_metadata = decode_transaction_status_meta_from_frame(
2030 block.slot,
2031 reassembled_metadata,
2032 )
2033 .map_err(|err| {
2034 (
2035 FirehoseError::NodeDecodingError(item_index, err),
2036 error_slot,
2037 )
2038 })?;
2039
2040 let message_hash = {
2041 #[cfg(feature = "verify-transaction-signatures")]
2042 {
2043 versioned_tx.verify_and_hash_message().map_err(|err| {
2044 (
2045 FirehoseError::TransactionHandlerError(Box::new(err)),
2046 error_slot,
2047 )
2048 })?
2049 }
2050 #[cfg(not(feature = "verify-transaction-signatures"))]
2051 {
2052 versioned_tx.message.hash()
2053 }
2054 };
2055 let signature = versioned_tx
2056 .signatures
2057 .first()
2058 .ok_or_else(|| {
2059 Box::new(std::io::Error::new(
2060 std::io::ErrorKind::InvalidData,
2061 "transaction missing signature",
2062 )) as SharedError
2063 })
2064 .map_err(|err| {
2065 (
2066 FirehoseError::NodeDecodingError(
2067 item_index,
2068 err,
2069 ),
2070 error_slot,
2071 )
2072 })?;
2073 let is_vote = is_simple_vote_transaction(&versioned_tx);
2074
2075 on_tx_cb(
2076 thread_index,
2077 TransactionData {
2078 slot: block.slot,
2079 transaction_slot_index: tx.index.unwrap() as usize,
2080 signature: *signature,
2081 message_hash,
2082 is_vote,
2083 transaction_status_meta: as_native_metadata,
2084 transaction: versioned_tx,
2085 },
2086 )
2087 .await
2088 .map_err(|e| {
2089 (
2090 FirehoseError::TransactionHandlerError(e),
2091 error_slot,
2092 )
2093 })?;
2094 }
2095 fetch_add_if(
2096 tracking_enabled,
2097 &overall_transactions_processed,
2098 1,
2099 );
2100 if let Some(ref mut stats) = thread_stats {
2101 stats.transactions_processed += 1;
2102 }
2103 transactions_since_stats.fetch_add(1, Ordering::Relaxed);
2104 thread_activity::add_transactions(thread_index, 1);
2105 }
2106 Entry(entry) => {
2107 let entry_hash = Hash::from(entry.hash.to_bytes());
2108 let entry_transaction_count = entry.transactions.len();
2109 let entry_transaction_count_u64 = entry_transaction_count as u64;
2110 let starting_transaction_index_u64 =
2111 this_block_executed_transaction_count;
2112 latest_entry_blockhash = entry_hash;
2113 this_block_executed_transaction_count += entry_transaction_count_u64;
2114 this_block_entry_count += 1;
2115
2116 if entry_enabled && let Some(on_entry_cb) = on_entry.as_ref() {
2117 let starting_transaction_index = usize::try_from(
2118 starting_transaction_index_u64,
2119 )
2120 .map_err(|err| {
2121 (
2122 FirehoseError::EntryHandlerError(Box::new(err)),
2123 error_slot,
2124 )
2125 })?;
2126 let transaction_indexes_end =
2127 starting_transaction_index + entry_transaction_count;
2128 on_entry_cb(
2129 thread_index,
2130 EntryData {
2131 slot: block.slot,
2132 entry_index,
2133 transaction_indexes: starting_transaction_index
2134 ..transaction_indexes_end,
2135 num_hashes: entry.num_hashes,
2136 hash: entry_hash,
2137 },
2138 )
2139 .await
2140 .map_err(|e| {
2141 (
2142 FirehoseError::EntryHandlerError(e),
2143 error_slot,
2144 )
2145 })?;
2146 }
2147 entry_index += 1;
2148 fetch_add_if(
2149 tracking_enabled,
2150 &overall_entries_processed,
2151 1,
2152 );
2153 if let Some(ref mut stats) = thread_stats {
2154 stats.entries_processed += 1;
2155 }
2156 }
2157 Block(block) => {
2158 let prev_last_counted_slot = last_counted_slot;
2159 let thread_stats_snapshot = thread_stats.as_ref().map(|stats| {
2160 (
2161 stats.slots_processed,
2162 stats.blocks_processed,
2163 stats.leader_skipped_slots,
2164 stats.current_slot,
2165 )
2166 });
2167
2168 let next_expected_slot = prev_last_counted_slot.saturating_add(1);
2169 let skip_start_from_previous = last_counted_slot.saturating_add(1);
2170 let skip_start = skip_start_from_previous.max(next_expected_slot);
2171
2172 let skipped_epoch = slot_to_epoch(last_counted_slot);
2173 for skipped_slot in skip_start..slot {
2174 if slot_to_epoch(skipped_slot) != skipped_epoch {
2175 break;
2176 }
2177 log::debug!(
2178 target: &log_target,
2179 "leader skipped slot {} (prev_counted {}, current slot {})",
2180 skipped_slot,
2181 prev_last_counted_slot,
2182 slot,
2183 );
2184 if block_enabled {
2185 pending_skipped_slots
2186 .entry(thread_index)
2187 .or_default()
2188 .insert(skipped_slot);
2189 }
2190 if block_enabled
2191 && let Some(on_block_cb) = on_block.as_ref()
2192 && skipped_slot > last_emitted_slot {
2193 last_emitted_slot = skipped_slot;
2194 on_block_cb(
2195 thread_index,
2196 BlockData::PossibleLeaderSkipped {
2197 slot: skipped_slot,
2198 },
2199 )
2200 .await
2201 .map_err(|e| {
2202 (
2203 FirehoseError::BlockHandlerError(e),
2204 error_slot,
2205 )
2206 })?;
2207 }
2208 if tracking_enabled {
2209 overall_slots_processed.fetch_add(1, Ordering::Relaxed);
2210 slots_since_stats.fetch_add(1, Ordering::Relaxed);
2211 if let Some(ref mut stats) = thread_stats {
2212 stats.leader_skipped_slots += 1;
2213 stats.slots_processed += 1;
2214 stats.current_slot = skipped_slot;
2215 }
2216 }
2217 last_counted_slot = skipped_slot;
2218 }
2219
2220 let cleared_pending_skip = if block_enabled {
2221 clear_pending_skip(
2222 &pending_skipped_slots,
2223 thread_index,
2224 slot,
2225 )
2226 } else {
2227 false
2228 };
2229
2230 if slot <= last_counted_slot && !cleared_pending_skip {
2231 log::debug!(
2232 target: &log_target,
2233 "duplicate block {}, already counted (last_counted={})",
2234 slot,
2235 last_counted_slot,
2236 );
2237 this_block_rewards = DecodedRewards::empty();
2238 continue;
2239 }
2240
2241 if block_enabled {
2242 if let Some(on_block_cb) = on_block.as_ref() {
2243 let DecodedRewards {
2244 keyed_rewards,
2245 num_partitions,
2246 } = std::mem::take(&mut this_block_rewards);
2247 if slot > last_emitted_slot {
2248 last_emitted_slot = slot;
2249 on_block_cb(
2250 thread_index,
2251 BlockData::Block {
2252 parent_slot: block.meta.parent_slot,
2253 parent_blockhash: previous_blockhash,
2254 slot: block.slot,
2255 blockhash: latest_entry_blockhash,
2256 rewards: KeyedRewardsAndNumPartitions {
2257 keyed_rewards,
2258 num_partitions,
2259 },
2260 block_time: Some(block.meta.blocktime as i64),
2261 block_height: block.meta.block_height,
2262 executed_transaction_count:
2263 this_block_executed_transaction_count,
2264 entry_count: this_block_entry_count,
2265 },
2266 )
2267 .await
2268 .map_err(|e| {
2269 (
2270 FirehoseError::BlockHandlerError(e),
2271 error_slot,
2272 )
2273 })?;
2274 }
2275 }
2276 } else {
2277 this_block_rewards = DecodedRewards::empty();
2278 }
2279 previous_blockhash = latest_entry_blockhash;
2280
2281 if tracking_enabled {
2282 overall_slots_processed.fetch_add(1, Ordering::Relaxed);
2283 overall_blocks_processed.fetch_add(1, Ordering::Relaxed);
2284 slots_since_stats.fetch_add(1, Ordering::Relaxed);
2285 blocks_since_stats.fetch_add(1, Ordering::Relaxed);
2286 if let Some(ref mut stats) = thread_stats {
2287 stats.blocks_processed += 1;
2288 stats.slots_processed += 1;
2289 stats.current_slot = slot;
2290 }
2291
2292 if let (Some(stats_tracking_cfg), Some(thread_stats_ref)) =
2293 (&stats_tracking, thread_stats.as_mut())
2294 && slot % stats_tracking_cfg.tracking_interval_slots == 0
2295 && let Err(err) = maybe_emit_stats(
2296 stats_tracking.as_ref(),
2297 thread_index,
2298 thread_stats_ref,
2299 &overall_slots_processed,
2300 &overall_blocks_processed,
2301 &overall_transactions_processed,
2302 &overall_entries_processed,
2303 &transactions_since_stats,
2304 &blocks_since_stats,
2305 &slots_since_stats,
2306 &last_pulse,
2307 start_time,
2308 )
2309 .await
2310 {
2311 blocks_since_stats.fetch_sub(1, Ordering::Relaxed);
2312 slots_since_stats.fetch_sub(1, Ordering::Relaxed);
2313 overall_blocks_processed
2314 .fetch_sub(1, Ordering::Relaxed);
2315 overall_slots_processed
2316 .fetch_sub(1, Ordering::Relaxed);
2317 if let Some((
2318 prev_slots_processed,
2319 prev_blocks_processed,
2320 prev_leader_skipped,
2321 prev_current_slot,
2322 )) = thread_stats_snapshot
2323 {
2324 thread_stats_ref.slots_processed =
2325 prev_slots_processed;
2326 thread_stats_ref.blocks_processed =
2327 prev_blocks_processed;
2328 thread_stats_ref.leader_skipped_slots =
2329 prev_leader_skipped;
2330 thread_stats_ref.current_slot =
2331 prev_current_slot;
2332 }
2333 last_counted_slot = prev_last_counted_slot;
2334 return Err(err);
2335 }
2336 }
2337
2338 if slot > last_counted_slot {
2339 last_counted_slot = slot;
2340 }
2341 if work_stealing {
2342 work_registry[thread_index]
2343 .next
2344 .store(last_counted_slot.saturating_add(1), Ordering::SeqCst);
2345 }
2346 }
2347 Subset(_subset) => (),
2348 Epoch(_epoch) => (),
2349 Rewards(rewards) => {
2350 if reward_enabled || block_enabled {
2351 let reassembled = nodes
2352 .reassemble_dataframes(&rewards.data)
2353 .map_err(|err| {
2354 (
2355 FirehoseError::NodeDecodingError(item_index, err),
2356 current_slot.unwrap_or(slot_range.start),
2357 )
2358 })?;
2359 if reassembled.is_empty() {
2360 this_block_rewards = DecodedRewards::empty();
2361 if reward_enabled
2362 && let Some(on_reward_cb) = on_reward.as_ref()
2363 {
2364 on_reward_cb(
2365 thread_index,
2366 RewardsData {
2367 slot: block.slot,
2368 rewards: Vec::new(),
2369 },
2370 )
2371 .await
2372 .map_err(|e| {
2373 (
2374 FirehoseError::RewardHandlerError(e),
2375 error_slot,
2376 )
2377 })?;
2378 }
2379 continue;
2380 }
2381
2382 let decoded_rewards =
2383 decode_rewards_from_frame(block.slot, reassembled)
2384 .map_err(|err| {
2385 (
2386 FirehoseError::NodeDecodingError(
2387 item_index,
2388 err,
2389 ),
2390 error_slot,
2391 )
2392 })?;
2393 if reward_enabled
2394 && let Some(on_reward_cb) = on_reward.as_ref()
2395 {
2396 on_reward_cb(
2397 thread_index,
2398 RewardsData {
2399 slot: block.slot,
2400 rewards: decoded_rewards.keyed_rewards.clone(),
2401 },
2402 )
2403 .await
2404 .map_err(|e| {
2405 (
2406 FirehoseError::RewardHandlerError(e),
2407 error_slot,
2408 )
2409 })?;
2410 }
2411 this_block_rewards = decoded_rewards;
2412 if let Some(ref mut stats) = thread_stats {
2413 stats.rewards_processed +=
2414 this_block_rewards.keyed_rewards.len() as u64;
2415 }
2416 }
2417 }
2418 DataFrame(_data_frame) => (),
2419 }
2420 }
2421 if !reverse_mode_local && block.slot == slot_range.end - 1 {
2422 let finish_time = std::time::Instant::now();
2423 let elapsed = finish_time.duration_since(start_time);
2424 log::info!(target: &log_target, "processed slot {}", block.slot);
2425 let elapsed_pretty = human_readable_duration(elapsed);
2426 log::info!(
2427 target: &log_target,
2428 "processed {} slots across {} epochs in {}.",
2429 slot_range.end - slot_range.start,
2430 slot_to_epoch(slot_range.end) + 1 - slot_to_epoch(slot_range.start),
2431 elapsed_pretty
2432 );
2433 log::info!(target: &log_target, "a 🚒 firehose thread completed its work.");
2434 let summary: String = error_counts
2437 .iter()
2438 .enumerate()
2439 .filter_map(|(i, c)| {
2440 let v = c.load(Ordering::Relaxed);
2441 if v > 0 {
2442 Some(format!("{:03}({})", i, v))
2443 } else {
2444 None
2445 }
2446 })
2447 .collect::<Vec<_>>()
2448 .join(", ");
2449 if !summary.is_empty() {
2450 log::debug!(target: &log_target, "threads with errors: {}", summary);
2451 }
2452 return Err((FirehoseError::RangeComplete, slot_range.end));
2453 }
2454 }
2455 if reverse_mode_local {
2456 if reverse_highest_remaining_epoch == Some(epoch_num) {
2459 reverse_highest_remaining_epoch = epoch_num.checked_sub(1);
2460 }
2461 if matches!(
2462 reverse_partial_resume,
2463 Some(s) if slot_to_epoch(s) == epoch_num
2464 ) {
2465 reverse_partial_resume = None;
2466 }
2467 }
2468 if let Some(expected_last_slot) = slot_range.end.checked_sub(1)
2469 && last_counted_slot < expected_last_slot
2470 {
2471 }
2474 if let Some(ref mut stats) = thread_stats {
2475 stats.finish_time = Some(std::time::Instant::now());
2476 maybe_emit_stats(
2477 stats_tracking.as_ref(),
2478 thread_index,
2479 stats,
2480 &overall_slots_processed,
2481 &overall_blocks_processed,
2482 &overall_transactions_processed,
2483 &overall_entries_processed,
2484 &transactions_since_stats,
2485 &blocks_since_stats,
2486 &slots_since_stats,
2487 &last_pulse,
2488 start_time,
2489 )
2490 .await?;
2491 }
2492 if block_enabled {
2493 pending_skipped_slots.remove(&thread_index);
2494 }
2495 log::info!(target: &log_target, "thread {} has finished its work", thread_index);
2496 }
2497 Err((FirehoseError::RangeComplete, slot_range.end))
2498 }
2499 .await
2500 {
2501 if is_shutdown_error(&err) {
2502 log::info!(
2503 target: &log_target,
2504 "shutdown requested; terminating firehose thread {}",
2505 thread_index
2506 );
2507 break;
2508 }
2509 if matches!(err, FirehoseError::RangeComplete) {
2512 if work_stealing {
2517 let assignment_start =
2518 work_registry[thread_index].start.load(Ordering::SeqCst);
2519 coverage_log
2520 .lock()
2521 .unwrap()
2522 .push((assignment_start, last_counted_slot.saturating_add(1)));
2523 }
2524 if work_stealing {
2528 work_registry[thread_index]
2529 .next
2530 .store(slot_range.end, Ordering::SeqCst);
2531 let drained_position = slot_range.end;
2532 service_steal_inbox(
2533 &mut steal_inbox,
2534 &mut slot_range,
2535 drained_position,
2536 &work_registry[thread_index],
2537 &log_target,
2538 false,
2539 );
2540 }
2541 if work_stealing
2542 && let Some((victim, stolen)) = request_steal(
2543 &work_registry,
2544 &steal_inboxes,
2545 &mut steal_inbox,
2546 thread_index,
2547 &steal_lock,
2548 )
2549 .await
2550 {
2551 thread_activity::note_steal();
2552 log::info!(
2553 target: &log_target,
2554 "🥷 stole {} slots ({}..{}) from thread {} (least progress)",
2555 stolen.end - stolen.start,
2556 stolen.start,
2557 stolen.end,
2558 victim
2559 );
2560 thread_activity::clear_finished(thread_index);
2561 slot_range = stolen;
2562 last_counted_slot = slot_range.start.saturating_sub(1);
2563 last_emitted_slot_global = slot_range.start.saturating_sub(1);
2564 reverse_partial_resume = None;
2565 skip_until_index = None;
2566 if let Some(ref mut stats) = thread_stats {
2567 stats.slot_range = slot_range.clone();
2568 stats.finish_time = None;
2569 }
2570 continue;
2571 }
2572 thread_activity::note_finished(thread_index);
2573 break;
2574 }
2575 let recycled = matches!(err, FirehoseError::ConnectionRecycled);
2578 let epoch = slot_to_epoch(slot);
2579 let item_index = match &err {
2580 FirehoseError::NodeDecodingError(item_index, _) => *item_index,
2581 _ => 0,
2582 };
2583 let error_message = err.to_string();
2584 if recycled {
2585 thread_activity::note_recycle();
2586 log::info!(
2587 target: &log_target,
2588 "♻️ recycling connection; resuming from slot {} in epoch {}",
2589 slot,
2590 epoch
2591 );
2592 } else {
2593 if matches!(err, FirehoseError::OperationTimeout(_)) {
2594 thread_activity::note_timeout();
2595 }
2596 log::error!(
2597 target: &log_target,
2598 "🧯💦🔥 firehose encountered an error at slot {} in epoch {} and will roll back one slot and retry:",
2599 slot,
2600 epoch
2601 );
2602 log::error!(target: &log_target, "{}", error_message);
2603 }
2604 if matches!(err, FirehoseError::SlotOffsetIndexError(_))
2605 || error_message.contains("Unknown CID version")
2606 {
2607 SLOT_OFFSET_INDEX.invalidate_epoch(epoch);
2610 }
2611 if !recycled {
2612 if let Some(on_error_cb) = on_error.clone() {
2613 let context = FirehoseErrorContext {
2614 thread_id: thread_index,
2615 slot,
2616 epoch,
2617 error_message: error_message.clone(),
2618 };
2619 if let Err(handler_err) = on_error_cb(thread_index, context).await {
2620 log::error!(
2621 target: &log_target,
2622 "on_error handler failed: {}",
2623 handler_err
2624 );
2625 }
2626 }
2627 error_counts[thread_index].fetch_add(1, Ordering::Relaxed);
2629 log::warn!(
2630 target: &log_target,
2631 "restarting from slot {} at index {}",
2632 slot,
2633 item_index,
2634 );
2635 }
2636 if reverse_mode_local {
2640 let (resume, highest) = reverse_resume_after_error(
2644 slot,
2645 last_counted_slot,
2646 reverse_highest_remaining_epoch,
2647 );
2648 reverse_partial_resume = resume;
2649 reverse_highest_remaining_epoch = highest;
2650 } else if slot <= last_counted_slot {
2651 slot_range.start = last_counted_slot.saturating_add(1);
2652 } else {
2653 slot_range.start = slot;
2654 }
2655 last_pulse.store(start_time.elapsed().as_nanos() as u64, Ordering::Relaxed);
2657 if tracking_enabled
2658 && let Some(ref mut stats_ref) = thread_stats {
2659 stats_ref.slot_range.start = slot_range.start;
2660 stats_ref.slot_range.end = slot_range.end;
2661 }
2663 if block_enabled {
2664 pending_skipped_slots.remove(&thread_index);
2665 }
2666 skip_until_index = None;
2670 last_emitted_slot_global = last_emitted_slot;
2671 if !recycled {
2672 let backoff = retry_backoff.next_delay(slot);
2673 log::warn!(
2674 target: &log_target,
2675 "backing off {:?} before restarting",
2676 backoff
2677 );
2678 let deadline = std::time::Instant::now() + backoff;
2682 let mut shutdown_requested = false;
2683 while std::time::Instant::now() < deadline {
2684 if poll_shutdown(&shutdown_flag, &mut shutdown_rx) {
2685 shutdown_requested = true;
2686 break;
2687 }
2688 if work_stealing {
2689 let resume_position = slot_range.start;
2690 service_steal_inbox(
2691 &mut steal_inbox,
2692 &mut slot_range,
2693 resume_position,
2694 &work_registry[thread_index],
2695 &log_target,
2696 true,
2697 );
2698 }
2699 sleep(std::time::Duration::from_millis(250)).await;
2700 }
2701 if shutdown_requested {
2702 log::info!(
2703 target: &log_target,
2704 "shutdown requested; terminating firehose thread {}",
2705 thread_index
2706 );
2707 break;
2708 }
2709 }
2710 }
2711 });
2712 handles.push(handle);
2713 }
2714
2715 for handle in handles {
2717 handle.await.unwrap();
2718 }
2719 if let Some(monitor) = recycle_monitor {
2720 monitor.abort();
2721 }
2722 if work_stealing && !shutdown_flag.load(Ordering::SeqCst) {
2726 let mut covered = coverage_log.lock().unwrap().clone();
2727 covered.retain(|(start, end)| end > start);
2728 covered.sort_unstable();
2729 let mut holes: Vec<(u64, u64)> = Vec::new();
2730 let mut cursor = overall_start;
2731 for (start, end) in covered {
2732 if start > cursor {
2733 holes.push((cursor, start));
2734 }
2735 cursor = cursor.max(end);
2736 }
2737 if cursor < overall_end {
2738 holes.push((cursor, overall_end));
2739 }
2740 let mut real_holes = 0usize;
2741 for (hole_start, hole_end) in &holes {
2742 if let Some(missing) = crate::index::next_present_slot(
2743 hole_start.saturating_sub(1),
2744 hole_end.saturating_sub(1),
2745 )
2746 .await
2747 {
2748 real_holes += 1;
2749 if real_holes <= 10 {
2750 log::error!(
2751 target: LOG_MODULE,
2752 "🕳️ coverage audit: slots [{}, {}) were never processed (first present slot: {})",
2753 hole_start,
2754 hole_end,
2755 missing
2756 );
2757 }
2758 }
2759 }
2760 if real_holes == 0 {
2761 log::info!(
2762 target: LOG_MODULE,
2763 "coverage audit passed: every present slot in [{}, {}) was processed",
2764 overall_start,
2765 overall_end
2766 );
2767 } else {
2768 log::error!(
2769 target: LOG_MODULE,
2770 "🕳️ coverage audit FAILED: {} hole(s) containing unprocessed slots — output is incomplete; re-run the listed ranges",
2771 real_holes
2772 );
2773 }
2774 }
2775 if stats_tracking.is_some() {
2776 let elapsed = firehose_start.elapsed();
2777 let elapsed_secs = elapsed.as_secs_f64();
2778 let total_slots = overall_slots_processed.load(Ordering::Relaxed);
2779 let total_blocks = overall_blocks_processed.load(Ordering::Relaxed);
2780 let total_transactions = overall_transactions_processed.load(Ordering::Relaxed);
2781 let total_leader_skipped = total_slots.saturating_sub(total_blocks);
2782 let total_errors: u64 = error_counts
2783 .iter()
2784 .map(|counter| counter.load(Ordering::Relaxed) as u64)
2785 .sum();
2786 let overall_tps = if elapsed_secs > 0.0 {
2787 total_transactions as f64 / elapsed_secs
2788 } else {
2789 0.0
2790 };
2791 log::info!(
2792 target: LOG_MODULE,
2793 "firehose summary: elapsed={:.2}s, slots={}, blocks={}, leader_skipped={}, transactions={}, overall_tps={:.2}, total_errors={}",
2794 elapsed_secs,
2795 total_slots,
2796 total_blocks,
2797 total_leader_skipped,
2798 total_transactions,
2799 overall_tps,
2800 total_errors
2801 );
2802 }
2803 if shutdown_flag.load(Ordering::SeqCst) {
2804 log::info!(target: LOG_MODULE, "firehose shutdown complete; all threads exited cleanly.");
2805 } else {
2806 log::info!(target: LOG_MODULE, "🚒 firehose finished successfully.");
2807 }
2808 Ok(())
2809}
2810
2811#[allow(clippy::result_large_err)]
2812pub fn firehose_geyser(
2819 rt: Arc<tokio::runtime::Runtime>,
2820 slot_range: Range<u64>,
2821 geyser_config_files: Option<&[PathBuf]>,
2822 index_base_url: &Url,
2823 client: &Client,
2824 on_load: impl Future<Output = Result<(), SharedError>> + Send + 'static,
2825 threads: u64,
2826) -> Result<Receiver<SlotNotification>, (FirehoseError, u64)> {
2827 if threads == 0 {
2828 return Err((
2829 FirehoseError::OnLoadError("Number of threads must be greater than 0".into()),
2830 slot_range.start,
2831 ));
2832 }
2833 log::info!(target: LOG_MODULE, "starting firehose...");
2834 log::info!(target: LOG_MODULE, "index base url: {}", index_base_url);
2835 let (confirmed_bank_sender, confirmed_bank_receiver) = unbounded();
2836 let mut entry_notifier_maybe = None;
2837 let mut block_meta_notifier_maybe = None;
2838 let mut transaction_notifier_maybe = None;
2839 if let Some(geyser_config_files) = geyser_config_files {
2840 log::debug!(target: LOG_MODULE, "geyser config files: {:?}", geyser_config_files);
2841
2842 let service =
2843 solana_geyser_plugin_manager::geyser_plugin_service::GeyserPluginService::new(
2844 confirmed_bank_receiver.clone(),
2845 true,
2846 geyser_config_files,
2847 )
2848 .map_err(|e| (e.into(), slot_range.start))?;
2849
2850 transaction_notifier_maybe = Some(
2851 service
2852 .get_transaction_notifier()
2853 .ok_or(FirehoseError::FailedToGetTransactionNotifier)
2854 .map_err(|e| (e, slot_range.start))?,
2855 );
2856
2857 entry_notifier_maybe = service.get_entry_notifier();
2858 block_meta_notifier_maybe = service.get_block_metadata_notifier();
2859
2860 log::debug!(target: LOG_MODULE, "geyser plugin service initialized.");
2861 }
2862
2863 if entry_notifier_maybe.is_some() {
2864 log::debug!(target: LOG_MODULE, "entry notifications enabled")
2865 } else {
2866 log::debug!(target: LOG_MODULE, "none of the plugins have enabled entry notifications")
2867 }
2868 log::info!(target: LOG_MODULE, "running on_load...");
2869 rt.spawn(on_load);
2870
2871 let slot_range = Arc::new(slot_range);
2872 let transaction_notifier_maybe = Arc::new(transaction_notifier_maybe);
2873 let entry_notifier_maybe = Arc::new(entry_notifier_maybe);
2874 let block_meta_notifier_maybe = Arc::new(block_meta_notifier_maybe);
2875 let confirmed_bank_sender = Arc::new(confirmed_bank_sender);
2876
2877 let subranges = generate_subranges(&slot_range, threads);
2879 if threads > 1 {
2880 log::info!(target: LOG_MODULE, "⚡ thread sub-ranges: {:?}", subranges);
2881 }
2882
2883 let mut handles = Vec::new();
2884 let error_counts: Arc<Vec<AtomicU32>> =
2886 Arc::new((0..subranges.len()).map(|_| AtomicU32::new(0)).collect());
2887
2888 for (i, slot_range) in subranges.into_iter().enumerate() {
2889 let transaction_notifier_maybe = (*transaction_notifier_maybe).clone();
2890 let entry_notifier_maybe = (*entry_notifier_maybe).clone();
2891 let block_meta_notifier_maybe = (*block_meta_notifier_maybe).clone();
2892 let confirmed_bank_sender = (*confirmed_bank_sender).clone();
2893 let client = client.clone();
2894 let error_counts = error_counts.clone();
2895
2896 let rt_clone = rt.clone();
2897
2898 let handle = std::thread::spawn(move || {
2899 rt_clone.block_on(async {
2900 firehose_geyser_thread(
2901 slot_range,
2902 transaction_notifier_maybe,
2903 entry_notifier_maybe,
2904 block_meta_notifier_maybe,
2905 confirmed_bank_sender,
2906 &client,
2907 if threads > 1 { Some(i) } else { None },
2908 error_counts,
2909 )
2910 .await
2911 .unwrap();
2912 });
2913 });
2914 handles.push(handle);
2915 }
2916
2917 for handle in handles {
2919 handle.join().unwrap();
2920 }
2921 log::info!(target: LOG_MODULE, "🚒 firehose finished successfully.");
2922 if let Some(block_meta_notifier) = block_meta_notifier_maybe.as_ref() {
2923 block_meta_notifier.notify_block_metadata(
2924 u64::MAX,
2925 "unload",
2926 u64::MAX,
2927 "unload",
2928 &KeyedRewardsAndNumPartitions {
2929 keyed_rewards: vec![],
2930 num_partitions: None,
2931 },
2932 None,
2933 None,
2934 0,
2935 0,
2936 );
2937 }
2938 Ok(confirmed_bank_receiver)
2939}
2940
2941#[allow(clippy::too_many_arguments)]
2942#[allow(clippy::result_large_err)]
2943async fn firehose_geyser_thread(
2944 mut slot_range: Range<u64>,
2945 transaction_notifier_maybe: Option<Arc<dyn TransactionNotifier + Send + Sync + 'static>>,
2946 entry_notifier_maybe: Option<Arc<dyn EntryNotifier + Send + Sync + 'static>>,
2947 block_meta_notifier_maybe: Option<Arc<dyn BlockMetadataNotifier + Send + Sync + 'static>>,
2948 confirmed_bank_sender: Sender<SlotNotification>,
2949 client: &Client,
2950 thread_index: Option<usize>,
2951 error_counts: Arc<Vec<AtomicU32>>,
2952) -> Result<(), (FirehoseError, u64)> {
2953 let start_time = std::time::Instant::now();
2954 let log_target = if let Some(thread_index) = thread_index {
2955 format!("{}::T{:03}", LOG_MODULE, thread_index)
2956 } else {
2957 LOG_MODULE.to_string()
2958 };
2959 let initial_slot_range = slot_range.clone();
2960 let mut skip_until_index = None;
2961 let mut last_counted_slot = slot_range.start.saturating_sub(1);
2962 let mut retry_backoff = RetryBackoff::new();
2963 while let Err((err, slot)) = async {
2965 let epoch_range = slot_to_epoch(slot_range.start)..=slot_to_epoch(slot_range.end - 1);
2966 log::info!(
2967 target: &log_target,
2968 "slot range: {} (epoch {}) ... {} (epoch {})",
2969 slot_range.start,
2970 slot_to_epoch(slot_range.start),
2971 slot_range.end,
2972 slot_to_epoch(slot_range.end)
2973 );
2974
2975 log::info!(target: &log_target, "🚒 starting firehose...");
2976
2977 let mut current_slot: Option<u64> = None;
2979 for epoch_num in epoch_range.clone() {
2980 log::info!(target: &log_target, "entering epoch {}", epoch_num);
2981 let stream = match timeout(OP_TIMEOUT, fetch_epoch_stream(epoch_num, client)).await {
2982 Ok(stream) => stream,
2983 Err(_) => {
2984 return Err((FirehoseError::OperationTimeout("fetch_epoch_stream"), current_slot.unwrap_or(slot_range.start)));
2985 }
2986 };
2987 let mut reader = NodeReader::new(stream);
2988
2989 let header_fut = reader.read_raw_header();
2990 let header = match timeout(OP_TIMEOUT, header_fut).await {
2991 Ok(res) => res
2992 .map_err(FirehoseError::ReadHeader)
2993 .map_err(|e| (e, current_slot.unwrap_or(slot_range.start)))?,
2994 Err(_) => {
2995 return Err((FirehoseError::OperationTimeout("read_raw_header"), current_slot.unwrap_or(slot_range.start)));
2996 }
2997 };
2998 log::debug!(target: &log_target, "read epoch {} header: {:?}", epoch_num, header);
2999
3000 let (epoch_start, epoch_end_inclusive) = epoch_to_slot_range(epoch_num);
3001 let local_start = std::cmp::max(slot_range.start, epoch_start);
3002 let local_end_inclusive =
3003 std::cmp::min(slot_range.end.saturating_sub(1), epoch_end_inclusive);
3004 if local_start > local_end_inclusive {
3005 log::debug!(
3006 target: &log_target,
3007 "epoch {} has no overlap with thread range ({}..{}), skipping",
3008 epoch_num,
3009 slot_range.start,
3010 slot_range.end
3011 );
3012 continue;
3013 }
3014
3015 let mut todo_previous_blockhash = Hash::default();
3016 let mut todo_latest_entry_blockhash = Hash::default();
3017 last_counted_slot = local_start.saturating_sub(1);
3020 current_slot = None;
3021
3022 if local_start > epoch_start {
3023 reader.prime_seek_permit().await;
3032 let seek_fut = reader.seek_to_slot(local_start);
3033 match timeout(OP_TIMEOUT, seek_fut).await {
3034 Ok(res) => res.map_err(|e| (e, local_start))?,
3035 Err(_) => {
3036 return Err((
3037 FirehoseError::OperationTimeout("seek_to_slot"),
3038 local_start,
3039 ));
3040 }
3041 }
3042 }
3043
3044 let mut item_index = 0;
3046 let mut displayed_skip_message = false;
3047 loop {
3048 let read_fut = reader.read_until_block();
3049 let nodes = match timeout(OP_TIMEOUT, read_fut).await {
3050 Ok(result) => result
3051 .map_err(FirehoseError::ReadUntilBlockError)
3052 .map_err(|e| (e, current_slot.unwrap_or(slot_range.start)))?,
3053 Err(_) => {
3054 log::warn!(target: &log_target, "timeout reading next block, retrying (will restart)...");
3055 let restart_slot =
3056 current_slot.map(|s| s + 1).unwrap_or(slot_range.start);
3057 return Err((
3058 FirehoseError::OperationTimeout("read_until_block"),
3059 restart_slot,
3060 ));
3061 }
3062 };
3063 thread_activity::note(thread_index.unwrap_or(0));
3064 let stream_ended = nodes.is_empty()
3065 || nodes
3066 .0
3067 .last()
3068 .is_some_and(|last_node| !last_node.get_node().is_block());
3069 if stream_ended {
3070 let scan_end = local_end_inclusive.min(slot_range.end.saturating_sub(1));
3073 if let Some(missing) =
3074 crate::index::next_present_slot(last_counted_slot, scan_end).await
3075 {
3076 log::warn!(
3077 target: &log_target,
3078 "stream ended prematurely in epoch {} — slot {} (and possibly more) still unprocessed; restarting",
3079 epoch_num,
3080 missing
3081 );
3082 return Err((
3083 FirehoseError::PrematureStreamEnd,
3084 last_counted_slot.saturating_add(1),
3085 ));
3086 }
3087 log::info!(target: &log_target, "reached end of epoch {}", epoch_num);
3088 break;
3089 }
3090 let block = nodes
3091 .get_block()
3092 .map_err(FirehoseError::GetBlockError)
3093 .map_err(|e| (e, current_slot.unwrap_or(slot_range.start)))?;
3094 log::debug!(
3095 target: &log_target,
3096 "read {} items from epoch {}, now at slot {}",
3097 item_index,
3098 epoch_num,
3099 block.slot
3100 );
3101 let slot = block.slot;
3102 if slot > local_end_inclusive {
3103 log::debug!(
3104 target: &log_target,
3105 "reached end of local slice at slot {} (epoch {}), stopping",
3106 slot,
3107 epoch_num
3108 );
3109 break;
3110 }
3111 if slot >= slot_range.end {
3112 log::info!(target: &log_target, "reached end of slot range at slot {}", slot);
3113 return Ok(());
3117 }
3118 debug_assert!(slot < slot_range.end, "processing out-of-range slot {} (end {})", slot, slot_range.end);
3119 if slot < local_start {
3120 if slot.saturating_add(1) == local_start {
3121 log::debug!(
3122 target: &log_target,
3123 "priming reader with preceding slot {}, skipping",
3124 slot
3125 );
3126 } else {
3127 log::warn!(
3128 target: &log_target,
3129 "encountered slot {} before start of range {}, skipping",
3130 slot,
3131 local_start
3132 );
3133 }
3134 continue;
3135 }
3136 current_slot = Some(slot);
3137 let mut entry_index: usize = 0;
3138 let mut this_block_executed_transaction_count: u64 = 0;
3139 let mut this_block_entry_count: u64 = 0;
3140 let mut this_block_rewards = DecodedRewards::empty();
3141
3142 if slot <= last_counted_slot {
3143 log::debug!(
3144 target: &log_target,
3145 "duplicate block {}, already counted (last_counted={})",
3146 slot,
3147 last_counted_slot,
3148 );
3149 continue;
3150 }
3151
3152 nodes.each(|node_with_cid| -> Result<(), SharedError> {
3153 item_index += 1;
3154 if let Some(skip) = skip_until_index {
3160 if item_index < skip {
3161 if !displayed_skip_message {
3162 log::info!(
3163 target: &log_target,
3164 "skipping until index {} (at {})",
3165 skip,
3166 item_index
3167 );
3168 displayed_skip_message = true;
3169 }
3170 return Ok(());
3171 } else {
3172 log::info!(
3173 target: &log_target,
3174 "reached target index {}, resuming...",
3175 skip
3176 );
3177 skip_until_index = None;
3178 }
3179 }
3180 let node = node_with_cid.get_node();
3181
3182 use crate::node::Node::*;
3183 match node {
3184 Transaction(tx) => {
3185 let versioned_tx = tx.as_parsed()?;
3186 let reassembled_metadata = nodes.reassemble_dataframes(&tx.metadata)?;
3187
3188 let as_native_metadata = decode_transaction_status_meta_from_frame(
3189 block.slot,
3190 reassembled_metadata,
3191 )?;
3192
3193 let message_hash = {
3194 #[cfg(feature = "verify-transaction-signatures")]
3195 {
3196 versioned_tx.verify_and_hash_message()?
3197 }
3198 #[cfg(not(feature = "verify-transaction-signatures"))]
3199 {
3200 versioned_tx.message.hash()
3203 }
3204 };
3205 let signature = versioned_tx
3206 .signatures
3207 .first()
3208 .ok_or_else(|| {
3209 Box::new(std::io::Error::new(
3210 std::io::ErrorKind::InvalidData,
3211 "transaction missing signature",
3212 )) as SharedError
3213 })?;
3214 let is_vote = is_simple_vote_transaction(&versioned_tx);
3215
3216 if let Some(transaction_notifier) = transaction_notifier_maybe.as_ref() {
3217 transaction_notifier.notify_transaction(
3218 block.slot,
3219 tx.index.unwrap() as usize,
3220 signature,
3221 &message_hash,
3222 is_vote,
3223 &as_native_metadata,
3224 &versioned_tx,
3225 );
3226 }
3227
3228 }
3229 Entry(entry) => {
3230 let entry_hash = Hash::from(entry.hash.to_bytes());
3231 let entry_transaction_count = entry.transactions.len();
3232 let entry_transaction_count_u64 = entry_transaction_count as u64;
3233 let starting_transaction_index =
3234 usize::try_from(this_block_executed_transaction_count).map_err(|_| {
3235 Box::new(std::io::Error::other(
3236 "transaction index exceeds usize range",
3237 )) as SharedError
3238 })?;
3239 todo_latest_entry_blockhash = entry_hash;
3240 this_block_executed_transaction_count += entry_transaction_count_u64;
3241 this_block_entry_count += 1;
3242 if entry_notifier_maybe.is_none() {
3243 return Ok(());
3244 }
3245 let entry_notifier = entry_notifier_maybe.as_ref().unwrap();
3246 let entry_summary = solana_entry::entry::EntrySummary {
3247 num_hashes: entry.num_hashes,
3248 hash: Hash::from(entry.hash.to_bytes()),
3249 num_transactions: entry_transaction_count_u64,
3250 };
3251 entry_notifier.notify_entry(
3252 block.slot,
3253 entry_index,
3254 &entry_summary,
3255 starting_transaction_index,
3256 );
3257 entry_index += 1;
3258 }
3259 Block(block) => {
3260 let notification = SlotNotification::Root((block.slot, block.meta.parent_slot));
3261 confirmed_bank_sender.send(notification).unwrap();
3262
3263 if block_meta_notifier_maybe.is_none() {
3264 last_counted_slot = block.slot;
3265 return Ok(());
3266 }
3267 let DecodedRewards {
3268 keyed_rewards,
3269 num_partitions,
3270 } = std::mem::take(&mut this_block_rewards);
3271 let block_meta_notifier = block_meta_notifier_maybe.as_ref().unwrap();
3272 block_meta_notifier.notify_block_metadata(
3273 block.meta.parent_slot,
3274 todo_previous_blockhash.to_string().as_str(),
3275 block.slot,
3276 todo_latest_entry_blockhash.to_string().as_str(),
3277 &KeyedRewardsAndNumPartitions {
3278 keyed_rewards,
3279 num_partitions,
3280 },
3281 Some(block.meta.blocktime as i64),
3282 block.meta.block_height,
3283 this_block_executed_transaction_count,
3284 this_block_entry_count,
3285 );
3286 todo_previous_blockhash = todo_latest_entry_blockhash;
3287 last_counted_slot = block.slot;
3288 std::thread::yield_now();
3289 }
3290 Subset(_subset) => (),
3291 Epoch(_epoch) => (),
3292 Rewards(rewards) => {
3293 let reassembled = nodes.reassemble_dataframes(&rewards.data)?;
3294 if !reassembled.is_empty() {
3295 this_block_rewards = decode_rewards_from_frame(
3296 block.slot,
3297 reassembled,
3298 )?;
3299 } else {
3300 this_block_rewards = DecodedRewards::empty();
3301 }
3302 }
3303 DataFrame(_data_frame) => (),
3304 }
3305 Ok(())
3306 })
3307 .map_err(|e| FirehoseError::NodeDecodingError(item_index, e)).map_err(|e| (e, current_slot.unwrap_or(slot_range.start)))?;
3308 if block.slot == slot_range.end - 1 {
3309 let finish_time = std::time::Instant::now();
3310 let elapsed = finish_time.duration_since(start_time);
3311 log::info!(target: &log_target, "processed slot {}", block.slot);
3312 let elapsed_pretty = human_readable_duration(elapsed);
3313 log::info!(
3314 target: &log_target,
3315 "processed {} slots across {} epochs in {}.",
3316 initial_slot_range.end - initial_slot_range.start,
3317 slot_to_epoch(initial_slot_range.end)
3318 + 1
3319 - slot_to_epoch(initial_slot_range.start),
3320 elapsed_pretty
3321 );
3322 log::info!(target: &log_target, "a 🚒 firehose thread finished completed its work.");
3323 thread_activity::note_finished(thread_index.unwrap_or(0));
3324 let summary: String = error_counts
3327 .iter()
3328 .enumerate()
3329 .filter_map(|(i, c)| {
3330 let v = c.load(Ordering::Relaxed);
3331 if v > 0 { Some(format!("{:03}({})", i, v)) } else { None }
3332 })
3333 .collect::<Vec<_>>()
3334 .join(", ");
3335 if !summary.is_empty() {
3336 log::debug!(target: &log_target, "threads with errors: {}", summary);
3337 }
3338 return Ok(());
3339 }
3340 }
3341 }
3342 Ok(())
3343}
3344.await
3345{
3346 if is_shutdown_error(&err) {
3347 log::info!(
3348 target: &log_target,
3349 "shutdown requested; terminating firehose thread {:?}",
3350 thread_index
3351 );
3352 return Ok(());
3353 }
3354 log::error!(
3355 target: &log_target,
3356 "🧯💦🔥 firehose encountered an error at slot {} in epoch {} and will roll back one slot and retry:",
3357 slot,
3358 slot_to_epoch(slot)
3359 );
3360 log::error!(target: &log_target, "{}", err);
3361 let error_message = err.to_string();
3362 if matches!(err, FirehoseError::SlotOffsetIndexError(_))
3363 || error_message.contains("Unknown CID version")
3364 {
3365 SLOT_OFFSET_INDEX.invalidate_epoch(slot_to_epoch(slot));
3368 }
3369 let item_index = match err {
3370 FirehoseError::NodeDecodingError(item_index, _) => item_index,
3371 _ => 0,
3372 };
3373 let idx = thread_index.unwrap_or(0);
3375 error_counts[idx].fetch_add(1, Ordering::Relaxed);
3376 log::warn!(
3377 target: &log_target,
3378 "restarting from slot {} at index {}",
3379 slot,
3380 item_index,
3381 );
3382 if slot <= last_counted_slot {
3385 slot_range.start = last_counted_slot.saturating_add(1);
3386 } else {
3387 slot_range.start = slot;
3388 }
3389 skip_until_index = None;
3393 let backoff = retry_backoff.next_delay(slot);
3394 log::warn!(
3395 target: &log_target,
3396 "backing off {:?} before restarting",
3397 backoff
3398 );
3399 sleep(backoff).await;
3400}
3401 Ok(())
3402}
3403
3404#[inline]
3405fn is_simple_vote_transaction(versioned_tx: &VersionedTransaction) -> bool {
3406 if !(1..=2).contains(&versioned_tx.signatures.len()) {
3407 return false;
3408 }
3409
3410 if !matches!(
3411 versioned_tx.version(),
3412 solana_transaction::versioned::TransactionVersion::Legacy(_)
3413 ) {
3414 return false;
3415 }
3416
3417 let instructions = versioned_tx.message.instructions();
3418 if instructions.len() != 1 {
3419 return false;
3420 }
3421
3422 let program_index = instructions[0].program_id_index as usize;
3423 versioned_tx
3424 .message
3425 .static_account_keys()
3426 .get(program_index)
3427 .map(|program_id| program_id == &vote_program_id())
3428 .unwrap_or(false)
3429}
3430
3431#[inline(always)]
3432fn convert_proto_rewards(
3433 proto_rewards: &solana_storage_proto::convert::generated::Rewards,
3434) -> Result<Vec<(Address, RewardInfo)>, SharedError> {
3435 let mut keyed_rewards = Vec::with_capacity(proto_rewards.rewards.len());
3436 for proto_reward in proto_rewards.rewards.iter() {
3437 let reward = RewardInfo {
3438 reward_type: match proto_reward.reward_type - 1 {
3439 0 => RewardType::Fee,
3440 1 => RewardType::Rent,
3441 2 => RewardType::Staking,
3442 3 => RewardType::Voting,
3443 typ => {
3444 return Err(Box::new(std::io::Error::other(format!(
3445 "unsupported reward type {}",
3446 typ
3447 ))));
3448 }
3449 },
3450 lamports: proto_reward.lamports,
3451 post_balance: proto_reward.post_balance,
3452 commission: proto_reward.commission.parse::<u8>().ok(),
3453 };
3454 let pubkey = proto_reward
3455 .pubkey
3456 .parse::<Address>()
3457 .map_err(|err| Box::new(err) as SharedError)?;
3458 keyed_rewards.push((pubkey, reward));
3459 }
3460 Ok(keyed_rewards)
3461}
3462
3463#[inline]
3464pub fn generate_subranges(slot_range: &Range<u64>, threads: u64) -> Vec<Range<u64>> {
3466 let total = slot_range.end - slot_range.start;
3467 let slots_per_thread = total / threads;
3468 let remainder = total % threads;
3469
3470 let ranges: Vec<Range<u64>> = (0..threads)
3471 .map(|i| {
3472 let extra_slot = if i < remainder { 1 } else { 0 };
3474 let start = slot_range.start + i * slots_per_thread + i.min(remainder);
3475 let end = start + slots_per_thread + extra_slot;
3476 start..end
3477 })
3478 .collect();
3479
3480 let total_covered: u64 = ranges.iter().map(|r| r.end - r.start).sum();
3482 assert_eq!(
3483 total_covered, total,
3484 "Range generation failed: {} threads should cover {} slots but only cover {}",
3485 threads, total, total_covered
3486 );
3487
3488 for i in 1..ranges.len() {
3490 assert_eq!(
3491 ranges[i - 1].end,
3492 ranges[i].start,
3493 "Gap found between thread {} (ends at {}) and thread {} (starts at {})",
3494 i - 1,
3495 ranges[i - 1].end,
3496 i,
3497 ranges[i].start
3498 );
3499 }
3500
3501 log::info!(
3502 target: LOG_MODULE,
3503 "Generated {} thread ranges covering {} slots total",
3504 threads,
3505 total_covered
3506 );
3507 ranges
3508}
3509
3510fn human_readable_duration(duration: std::time::Duration) -> String {
3511 if duration.is_zero() {
3512 return "0s".into();
3513 }
3514 let total_secs = duration.as_secs();
3515 if total_secs < 60 {
3516 let secs_f = duration.as_secs_f64();
3517 if total_secs == 0 {
3518 format!("{:.2}s", secs_f)
3519 } else if duration.subsec_millis() == 0 {
3520 format!("{}s", total_secs)
3521 } else {
3522 format!("{:.2}s", secs_f)
3523 }
3524 } else {
3525 let mut secs = total_secs;
3526 let days = secs / 86_400;
3527 secs %= 86_400;
3528 let hours = secs / 3_600;
3529 secs %= 3_600;
3530 let minutes = secs / 60;
3531 secs %= 60;
3532 if days > 0 {
3533 if hours > 0 {
3534 format!("{days}d{hours}h")
3535 } else {
3536 format!("{days}d")
3537 }
3538 } else if hours > 0 {
3539 if minutes > 0 {
3540 format!("{hours}h{minutes}m")
3541 } else {
3542 format!("{hours}h")
3543 }
3544 } else if minutes > 0 {
3545 if secs > 0 {
3546 format!("{minutes}m{secs}s")
3547 } else {
3548 format!("{minutes}m")
3549 }
3550 } else {
3551 format!("{secs}s")
3552 }
3553 }
3554}
3555
3556#[cfg(test)]
3557mod reverse_resume_tests {
3558 use super::*;
3559
3560 #[test]
3563 fn test_mid_epoch_error_resumes_in_place() {
3564 let (resume, highest) = reverse_resume_after_error(388799951, 388799950, Some(899));
3565 assert_eq!(resume, Some(388799951));
3566 assert_eq!(highest, Some(899));
3567 }
3568
3569 #[test]
3570 fn test_tail_timeout_marks_epoch_complete() {
3571 let (resume, highest) = reverse_resume_after_error(388800000, 388799999, Some(899));
3574 assert_eq!(resume, None);
3575 assert_eq!(highest, Some(898));
3576 }
3577
3578 #[test]
3579 fn test_tail_error_attributed_within_epoch_marks_complete() {
3580 let (resume, highest) = reverse_resume_after_error(388799999, 388799999, Some(899));
3583 assert_eq!(resume, None);
3584 assert_eq!(highest, Some(898));
3585 }
3586
3587 #[test]
3588 fn test_seek_error_before_any_progress_keeps_epoch() {
3589 let (resume, highest) = reverse_resume_after_error(388800000, 388799899, Some(900));
3592 assert_eq!(resume, None);
3593 assert_eq!(highest, Some(900));
3594 }
3595
3596 #[test]
3597 fn test_lower_epoch_seek_error_after_higher_done_resumes() {
3598 let (resume, highest) = reverse_resume_after_error(388799900, 388800099, Some(899));
3602 assert_eq!(resume, Some(388800100));
3603 assert_eq!(highest, Some(899));
3604 }
3605
3606 #[test]
3607 fn test_epoch_zero_tail_error_completes_run() {
3608 let (resume, highest) = reverse_resume_after_error(432000, 431999, Some(0));
3612 assert_eq!(resume, None);
3613 assert_eq!(highest, None);
3614 }
3615}
3616
3617#[cfg(test)]
3618mod steal_protocol_tests {
3619 use super::*;
3620
3621 fn slice(start: u64, next: u64, end: u64) -> WorkSlice {
3622 WorkSlice {
3623 start: AtomicU64::new(start),
3624 next: AtomicU64::new(next),
3625 end: AtomicU64::new(end),
3626 }
3627 }
3628
3629 #[test]
3630 fn test_grant_splits_remaining_and_commits() {
3631 let (tx, mut rx) = mpsc::unbounded_channel();
3632 let ledger = slice(1000, 1100, 1200);
3633 let mut range = 1000..1200;
3634 let (reply_tx, mut reply_rx) = oneshot::channel();
3635 tx.send(StealRequest { reply: reply_tx }).unwrap();
3636 service_steal_inbox(&mut rx, &mut range, 1100, &ledger, "test", true);
3637 assert_eq!(reply_rx.try_recv().unwrap(), Some(1150..1200));
3638 assert_eq!(range.end, 1150);
3639 assert_eq!(ledger.end.load(Ordering::SeqCst), 1150);
3640 }
3641
3642 #[test]
3643 fn test_rejects_below_min_steal() {
3644 let (tx, mut rx) = mpsc::unbounded_channel();
3645 let ledger = slice(1000, 1100, 1150);
3646 let mut range = 1000..1150;
3647 let (reply_tx, mut reply_rx) = oneshot::channel();
3648 tx.send(StealRequest { reply: reply_tx }).unwrap();
3649 service_steal_inbox(&mut rx, &mut range, 1100, &ledger, "test", true);
3650 assert_eq!(reply_rx.try_recv().unwrap(), None);
3651 assert_eq!(range.end, 1150);
3652 assert_eq!(ledger.end.load(Ordering::SeqCst), 1150);
3653 }
3654
3655 #[test]
3656 fn test_drain_mode_refuses_even_with_work() {
3657 let (tx, mut rx) = mpsc::unbounded_channel();
3658 let ledger = slice(1000, 1000, 2000);
3659 let mut range = 1000..2000;
3660 let (reply_tx, mut reply_rx) = oneshot::channel();
3661 tx.send(StealRequest { reply: reply_tx }).unwrap();
3662 service_steal_inbox(&mut rx, &mut range, 1000, &ledger, "test", false);
3663 assert_eq!(reply_rx.try_recv().unwrap(), None);
3664 assert_eq!(range.end, 2000);
3665 }
3666
3667 #[test]
3668 fn test_abandoned_request_does_not_commit() {
3669 let (tx, mut rx) = mpsc::unbounded_channel();
3670 let ledger = slice(1000, 1100, 1200);
3671 let mut range = 1000..1200;
3672 let (reply_tx, reply_rx) = oneshot::channel();
3673 tx.send(StealRequest { reply: reply_tx }).unwrap();
3674 drop(reply_rx);
3677 service_steal_inbox(&mut rx, &mut range, 1100, &ledger, "test", true);
3678 assert_eq!(range.end, 1200);
3679 assert_eq!(ledger.end.load(Ordering::SeqCst), 1200);
3680 }
3681}
3682
3683#[cfg(test)]
3684fn log_stats_handler(thread_id: usize, stats: Stats) -> HandlerFuture {
3685 Box::pin(async move {
3686 let elapsed = stats.start_time.elapsed();
3687 let elapsed_secs = elapsed.as_secs_f64();
3688 let tps = if elapsed_secs > 0.0 {
3689 stats.transactions_processed as f64 / elapsed_secs
3690 } else {
3691 0.0
3692 };
3693 log::info!(
3694 target: LOG_MODULE,
3695 "thread {thread_id} stats: current_slot={}, slots_processed={}, blocks_processed={}, txs={}, entries={}, rewards={}, elapsed_s={:.2}, tps={:.2}",
3696 stats.thread_stats.current_slot,
3697 stats.slots_processed,
3698 stats.blocks_processed,
3699 stats.transactions_processed,
3700 stats.entries_processed,
3701 stats.rewards_processed,
3702 elapsed_secs,
3703 tps
3704 );
3705 Ok(())
3706 })
3707}
3708
3709#[cfg(test)]
3710use futures_util::FutureExt;
3711#[cfg(test)]
3712use serial_test::serial;
3713#[cfg(test)]
3714use std::sync::{Mutex, OnceLock};
3715
3716#[cfg(test)]
3717async fn assert_slot_min_executed_transactions(slot: u64, min_executed: u64) {
3718 use std::sync::Arc;
3719 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
3720
3721 let found = Arc::new(AtomicBool::new(false));
3722 let observed_total = Arc::new(AtomicU64::new(0));
3723 let observed_non_vote = Arc::new(AtomicU64::new(0));
3724
3725 let found_block = found.clone();
3726 let observed_total_block = observed_total.clone();
3727 let target_slot_block = slot;
3728 let target_slot_tx = slot;
3729 let observed_non_vote_tx = observed_non_vote.clone();
3730
3731 firehose(
3732 1,
3733 false,
3734 false,
3735 None,
3736 target_slot_block..(target_slot_block + 1),
3737 Some(move |_thread_id: usize, block: BlockData| {
3738 let found_block = found_block.clone();
3739 let observed_total_block = observed_total_block.clone();
3740 async move {
3741 if block.slot() == target_slot_block {
3742 assert!(
3743 !block.was_skipped(),
3744 "slot {target_slot_block} was marked leader skipped",
3745 );
3746 if let BlockData::Block {
3747 executed_transaction_count,
3748 ..
3749 } = block
3750 {
3751 found_block.store(true, Ordering::Relaxed);
3752 observed_total_block.store(executed_transaction_count, Ordering::Relaxed);
3753 }
3754 }
3755 Ok(())
3756 }
3757 .boxed()
3758 }),
3759 Some(move |_thread_id: usize, transaction: TransactionData| {
3760 let observed_non_vote_tx = observed_non_vote_tx.clone();
3761 async move {
3762 if transaction.slot == target_slot_tx && !transaction.is_vote {
3763 observed_non_vote_tx.fetch_add(1, Ordering::Relaxed);
3764 }
3765 Ok(())
3766 }
3767 .boxed()
3768 }),
3769 None::<OnEntryFn>,
3770 None::<OnRewardFn>,
3771 None::<OnErrorFn>,
3772 None::<OnStatsTrackingFn>,
3773 None,
3774 )
3775 .await
3776 .unwrap();
3777
3778 assert!(
3779 found.load(Ordering::Relaxed),
3780 "target slot {slot} was not processed"
3781 );
3782 let observed_total = observed_total.load(Ordering::Relaxed);
3783 let observed_non_vote = observed_non_vote.load(Ordering::Relaxed);
3784 assert!(
3785 observed_total > 0,
3786 "slot {slot} executed transaction count was zero"
3787 );
3788 assert!(
3789 observed_total >= min_executed,
3790 "slot {slot} executed transaction count {observed_total} is below expected minimum {min_executed}"
3791 );
3792 log::info!(
3793 target: LOG_MODULE,
3794 "slot {slot} executed_tx_count={}, non_vote_tx_count={}",
3795 observed_total,
3796 observed_non_vote
3797 );
3798}
3799
3800#[cfg(test)]
3801async fn log_slot_node_summary(slot: u64) -> Result<(), SharedError> {
3802 use crate::index::slot_to_offset;
3803 use crate::node::Node;
3804
3805 let epoch = slot_to_epoch(slot);
3806 let client = crate::network::create_http_client();
3807 let stream = fetch_epoch_stream(epoch, &client).await;
3808 let mut reader = NodeReader::new(stream);
3809 reader
3810 .seek_to_slot(slot)
3811 .await
3812 .map_err(|err| Box::new(err) as SharedError)?;
3813
3814 let nodes = reader.read_until_block().await?;
3815 let mut transactions = 0u64;
3816 let mut entries = 0u64;
3817 let mut entry_tx_total = 0u64;
3818 let mut dataframes = 0u64;
3819 let mut rewards = 0u64;
3820 let mut subsets = 0u64;
3821 let mut epochs = 0u64;
3822 let mut block_slot = None;
3823 let mut block_entries = None;
3824 let first_kind = nodes
3825 .0
3826 .first()
3827 .map(|node| node.get_node())
3828 .map(|node| match node {
3829 Node::Transaction(_) => "transaction",
3830 Node::Entry(_) => "entry",
3831 Node::Block(_) => "block",
3832 Node::Subset(_) => "subset",
3833 Node::Epoch(_) => "epoch",
3834 Node::Rewards(_) => "rewards",
3835 Node::DataFrame(_) => "dataframe",
3836 })
3837 .unwrap_or("none");
3838
3839 for node in &nodes.0 {
3840 match node.get_node() {
3841 Node::Transaction(_) => {
3842 transactions += 1;
3843 }
3844 Node::Entry(entry) => {
3845 entries += 1;
3846 entry_tx_total += entry.transactions.len() as u64;
3847 }
3848 Node::Block(block) => {
3849 block_slot = Some(block.slot);
3850 block_entries = Some(block.entries.len());
3851 }
3852 Node::Subset(_) => {
3853 subsets += 1;
3854 }
3855 Node::Epoch(_) => {
3856 epochs += 1;
3857 }
3858 Node::Rewards(_) => {
3859 rewards += 1;
3860 }
3861 Node::DataFrame(_) => {
3862 dataframes += 1;
3863 }
3864 }
3865 }
3866
3867 log::info!(
3868 target: LOG_MODULE,
3869 "slot {slot} node summary: total_nodes={}, first_kind={}, tx_nodes={}, entry_nodes={}, entry_tx_total={}, block_slot={:?}, block_entries={:?}, dataframes={}, rewards={}, subsets={}, epochs={}",
3870 nodes.len(),
3871 first_kind,
3872 transactions,
3873 entries,
3874 entry_tx_total,
3875 block_slot,
3876 block_entries,
3877 dataframes,
3878 rewards,
3879 subsets,
3880 epochs
3881 );
3882
3883 if slot > 0 {
3884 let mut found_previous = None;
3885 for delta in 1..=5 {
3886 let candidate = slot.saturating_sub(delta);
3887 match slot_to_offset(candidate).await {
3888 Ok(offset) => {
3889 found_previous = Some((candidate, offset));
3890 break;
3891 }
3892 Err(err) => {
3893 log::info!(
3894 target: LOG_MODULE,
3895 "slot {slot} previous lookup {candidate} failed: {err}"
3896 );
3897 }
3898 }
3899 }
3900 if let Some((candidate, offset)) = found_previous {
3901 log::info!(
3902 target: LOG_MODULE,
3903 "slot {slot} nearest previous offset within 5 slots: slot {candidate} @ {offset}"
3904 );
3905 } else {
3906 log::info!(
3907 target: LOG_MODULE,
3908 "slot {slot} no previous offsets found within 5 slots"
3909 );
3910 }
3911 }
3912
3913 Ok(())
3914}
3915
3916#[tokio::test(flavor = "multi_thread")]
3917async fn test_firehose_epoch_800() {
3918 use dashmap::DashSet;
3919 use std::sync::atomic::{AtomicU64, Ordering};
3920 solana_logger::setup_with_default("info");
3921 const THREADS: usize = 4;
3922 const NUM_SLOTS_TO_COVER: u64 = 50;
3923 static PREV_BLOCK: [AtomicU64; THREADS] = [const { AtomicU64::new(0) }; THREADS];
3924 static NUM_SKIPPED_BLOCKS: AtomicU64 = AtomicU64::new(0);
3925 static NUM_BLOCKS: AtomicU64 = AtomicU64::new(0);
3926 static SEEN_SKIPPED: OnceLock<DashSet<u64>> = OnceLock::new();
3927 static SEEN_SLOTS: OnceLock<DashSet<u64>> = OnceLock::new();
3928 static MIN_TRANSACTIONS: AtomicU64 = AtomicU64::new(u64::MAX);
3929 let stats_tracking = StatsTracking {
3930 on_stats: log_stats_handler,
3931 tracking_interval_slots: 10,
3932 };
3933
3934 for prev in PREV_BLOCK.iter() {
3935 prev.store(0, Ordering::Relaxed);
3936 }
3937 NUM_SKIPPED_BLOCKS.store(0, Ordering::Relaxed);
3938 NUM_BLOCKS.store(0, Ordering::Relaxed);
3939 MIN_TRANSACTIONS.store(u64::MAX, Ordering::Relaxed);
3940 SEEN_SLOTS.get_or_init(DashSet::new).clear();
3941 SEEN_SKIPPED.get_or_init(DashSet::new).clear();
3942
3943 firehose(
3944 THREADS.try_into().unwrap(),
3945 false,
3946 false,
3947 None,
3948 (345600000 - NUM_SLOTS_TO_COVER / 2)..(345600000 + NUM_SLOTS_TO_COVER / 2),
3949 Some(|thread_id: usize, block: BlockData| {
3950 async move {
3951 let _prev =
3952 PREV_BLOCK[thread_id % PREV_BLOCK.len()].swap(block.slot(), Ordering::Relaxed);
3953 if block.was_skipped() {
3954 log::info!(
3955 target: LOG_MODULE,
3956 "leader skipped block {} on thread {}",
3957 block.slot(),
3958 thread_id,
3959 );
3960 } else {
3961 }
3968
3969 let first_time = SEEN_SLOTS.get_or_init(DashSet::new).insert(block.slot());
3970 if block.was_skipped() {
3971 NUM_SKIPPED_BLOCKS.fetch_add(1, Ordering::Relaxed);
3972 SEEN_SKIPPED.get_or_init(DashSet::new).insert(block.slot());
3973 } else if first_time {
3974 NUM_BLOCKS.fetch_add(1, Ordering::Relaxed);
3975 if let BlockData::Block {
3976 executed_transaction_count,
3977 ..
3978 } = &block
3979 {
3980 let executed = *executed_transaction_count;
3981 let _ = MIN_TRANSACTIONS.fetch_update(
3982 Ordering::Relaxed,
3983 Ordering::Relaxed,
3984 |current| {
3985 if executed < current {
3986 Some(executed)
3987 } else {
3988 None
3989 }
3990 },
3991 );
3992 }
3993 }
3994 Ok(())
3995 }
3996 .boxed()
3997 }),
3998 None::<OnTxFn>,
3999 None::<OnEntryFn>,
4000 None::<OnRewardFn>,
4001 None::<OnErrorFn>,
4002 Some(stats_tracking),
4003 None,
4004 )
4005 .await
4006 .unwrap();
4007 let seen = SEEN_SLOTS.get_or_init(DashSet::new).len() as u64;
4008 assert_eq!(
4009 seen, NUM_SLOTS_TO_COVER,
4010 "expected to see exactly {NUM_SLOTS_TO_COVER} unique slots, saw {seen}"
4011 );
4012 let mut skipped: Vec<u64> = SEEN_SKIPPED
4013 .get_or_init(DashSet::new)
4014 .iter()
4015 .map(|v| *v)
4016 .collect();
4017 skipped.sort_unstable();
4018 const EXPECTED_SKIPPED: [u64; 6] = [
4020 345_600_004,
4021 345_600_005,
4022 345_600_008,
4023 345_600_009,
4024 345_600_010,
4025 345_600_011,
4026 ];
4027 assert_eq!(skipped, EXPECTED_SKIPPED, "unexpected skipped slots");
4028 assert!(NUM_BLOCKS.load(Ordering::Relaxed) > 0);
4029}
4030
4031#[tokio::test(flavor = "multi_thread")]
4032async fn test_firehose_target_slot_transactions() {
4033 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4034 solana_logger::setup_with_default("info");
4035 const TARGET_SLOT: u64 = 376_273_722;
4036 const SLOT_RADIUS: u64 = 50;
4037 const EXPECTED_TRANSACTIONS: u64 = 1414;
4038 const EXPECTED_NON_VOTE_TRANSACTIONS: u64 = 511;
4039 static FOUND: AtomicBool = AtomicBool::new(false);
4040 static OBSERVED_TXS: AtomicU64 = AtomicU64::new(0);
4041 static OBSERVED_NON_VOTE: AtomicU64 = AtomicU64::new(0);
4042
4043 FOUND.store(false, Ordering::Relaxed);
4044 OBSERVED_TXS.store(0, Ordering::Relaxed);
4045 OBSERVED_NON_VOTE.store(0, Ordering::Relaxed);
4046
4047 firehose(
4048 4,
4049 false,
4050 false,
4051 None,
4052 (TARGET_SLOT - SLOT_RADIUS)..(TARGET_SLOT + SLOT_RADIUS),
4053 Some(|_thread_id: usize, block: BlockData| {
4054 async move {
4055 if block.slot() == TARGET_SLOT {
4056 assert!(
4057 !block.was_skipped(),
4058 "target slot {TARGET_SLOT} was marked leader skipped",
4059 );
4060 if let BlockData::Block {
4061 executed_transaction_count,
4062 ..
4063 } = block
4064 {
4065 OBSERVED_TXS.store(executed_transaction_count, Ordering::Relaxed);
4066 FOUND.store(true, Ordering::Relaxed);
4067 assert_eq!(
4068 executed_transaction_count, EXPECTED_TRANSACTIONS,
4069 "unexpected transaction count for slot {TARGET_SLOT}"
4070 );
4071 assert_eq!(
4072 OBSERVED_NON_VOTE.load(Ordering::Relaxed),
4073 EXPECTED_NON_VOTE_TRANSACTIONS,
4074 "unexpected non-vote transaction count for slot {TARGET_SLOT}"
4075 );
4076 }
4077 }
4078 Ok(())
4079 }
4080 .boxed()
4081 }),
4082 Some(|_thread_id: usize, transaction: TransactionData| {
4083 async move {
4084 if transaction.slot == TARGET_SLOT && !transaction.is_vote {
4085 OBSERVED_NON_VOTE.fetch_add(1, Ordering::Relaxed);
4086 }
4087 Ok(())
4088 }
4089 .boxed()
4090 }),
4091 None::<OnEntryFn>,
4092 None::<OnRewardFn>,
4093 None::<OnErrorFn>,
4094 None::<OnStatsTrackingFn>,
4095 None,
4096 )
4097 .await
4098 .unwrap();
4099
4100 assert!(
4101 FOUND.load(Ordering::Relaxed),
4102 "target slot was not processed"
4103 );
4104 assert_eq!(
4105 OBSERVED_TXS.load(Ordering::Relaxed),
4106 EXPECTED_TRANSACTIONS,
4107 "recorded transaction count mismatch"
4108 );
4109}
4110
4111#[cfg(test)]
4112#[serial]
4113#[tokio::test(flavor = "multi_thread")]
4114async fn test_firehose_epoch_900_boundary_window_sequential_monotonic_transactions() {
4115 use std::sync::{
4116 Arc, Mutex,
4117 atomic::{AtomicU64, Ordering},
4118 };
4119
4120 solana_logger::setup_with_default("info");
4121 const SLOT_COUNT: u64 = 100;
4122 const THREADS: u64 = 4;
4123 const TEST_BUFFER_WINDOW: &str = "4GiB";
4124
4125 let (epoch_900_start, _) = epoch_to_slot_range(900);
4126 let slot_range = (epoch_900_start - SLOT_COUNT)..(epoch_900_start + SLOT_COUNT);
4127
4128 let last_seen_tx_slot = Arc::new(Mutex::new(slot_range.start));
4129 let observed_txs = Arc::new(AtomicU64::new(0));
4130 let stats_tracking = StatsTracking {
4131 on_stats: log_stats_handler,
4132 tracking_interval_slots: 100,
4133 };
4134 let test_buffer_window_bytes = crate::system::parse_buffer_window_bytes(TEST_BUFFER_WINDOW)
4135 .expect("valid test buffer window");
4136
4137 firehose(
4138 THREADS,
4139 true,
4140 false,
4141 Some(test_buffer_window_bytes),
4142 slot_range.clone(),
4143 None::<OnBlockFn>,
4144 Some({
4145 let last_seen_tx_slot = last_seen_tx_slot.clone();
4146 let observed_txs = observed_txs.clone();
4147 move |_thread_id: usize, transaction: TransactionData| {
4148 let last_seen_tx_slot = last_seen_tx_slot.clone();
4149 let observed_txs = observed_txs.clone();
4150 async move {
4151 let mut previous = last_seen_tx_slot.lock().unwrap();
4152 assert!(
4155 transaction.slot >= *previous,
4156 "transaction slot regressed: prev={}, current={}",
4157 *previous,
4158 transaction.slot
4159 );
4160 *previous = transaction.slot;
4161 observed_txs.fetch_add(1, Ordering::Relaxed);
4162 Ok(())
4163 }
4164 .boxed()
4165 }
4166 }),
4167 None::<OnEntryFn>,
4168 None::<OnRewardFn>,
4169 None::<OnErrorFn>,
4170 Some(stats_tracking),
4171 None,
4172 )
4173 .await
4174 .unwrap();
4175
4176 assert!(
4177 observed_txs.load(Ordering::Relaxed) > 0,
4178 "expected to observe at least one transaction in slots [{}, {})",
4179 slot_range.start,
4180 slot_range.end
4181 );
4182}
4183
4184#[cfg(test)]
4185#[serial]
4186#[tokio::test(flavor = "multi_thread")]
4187async fn test_firehose_epoch_720_slot_311173980_solscan_non_vote_counts() {
4188 solana_logger::setup_with_default("info");
4189 assert_slot_min_executed_transactions(311_173_980, 1_197 + 211).await;
4190}
4191
4192#[cfg(test)]
4193#[serial]
4194#[tokio::test(flavor = "multi_thread")]
4195async fn test_firehose_epoch_720_slot_311225232_solscan_non_vote_counts() {
4196 solana_logger::setup_with_default("info");
4197 assert_slot_min_executed_transactions(311_225_232, 888 + 157).await;
4198}
4199
4200#[cfg(test)]
4201#[serial]
4202#[tokio::test(flavor = "multi_thread")]
4203async fn test_firehose_epoch_720_slot_311175860_solscan_non_vote_counts() {
4204 solana_logger::setup_with_default("info");
4205 assert_slot_min_executed_transactions(311_175_860, 527 + 110).await;
4206}
4207
4208#[cfg(test)]
4209#[serial]
4210#[tokio::test(flavor = "multi_thread")]
4211async fn test_firehose_epoch_720_slot_311134608_solscan_non_vote_counts() {
4212 solana_logger::setup_with_default("info");
4213 assert_slot_min_executed_transactions(311_134_608, 1_086 + 169).await;
4214}
4215
4216#[cfg(test)]
4217#[ignore]
4218#[serial]
4219#[tokio::test(flavor = "multi_thread")]
4220async fn debug_epoch_720_slot_311173980_node_summary() {
4221 solana_logger::setup_with_default("info");
4222 const SLOTS: &[u64] = &[
4223 311_173_980,
4224 311_225_232,
4225 311_175_860,
4226 311_134_608,
4227 376_273_722,
4228 ];
4229 for slot in SLOTS {
4230 log_slot_node_summary(*slot).await.expect("slot summary");
4231 }
4232}
4233
4234#[tokio::test(flavor = "multi_thread")]
4235async fn test_firehose_epoch_850_has_logs() {
4236 use std::sync::atomic::{AtomicU64, Ordering};
4237 solana_logger::setup_with_default("info");
4238 const START_SLOT: u64 = 367_200_075; const SLOT_COUNT: u64 = 50;
4240 static TOTAL_TXS: AtomicU64 = AtomicU64::new(0);
4241
4242 TOTAL_TXS.store(0, Ordering::Relaxed);
4243
4244 firehose(
4245 4,
4246 false,
4247 false,
4248 None,
4249 START_SLOT..(START_SLOT + SLOT_COUNT),
4250 None::<OnBlockFn>,
4251 Some(|_thread_id: usize, transaction: TransactionData| {
4252 async move {
4253 TOTAL_TXS.fetch_add(1, Ordering::Relaxed);
4254 if let Some(logs) = transaction.transaction_status_meta.log_messages.as_ref() {
4255 let has_logs = logs.iter().any(|msg| !msg.is_empty());
4256 assert!(has_logs);
4257 }
4258 Ok(())
4259 }
4260 .boxed()
4261 }),
4262 None::<OnEntryFn>,
4263 None::<OnRewardFn>,
4264 None::<OnErrorFn>,
4265 None::<OnStatsTrackingFn>,
4266 None,
4267 )
4268 .await
4269 .unwrap();
4270
4271 assert!(
4272 TOTAL_TXS.load(Ordering::Relaxed) > 0,
4273 "no transactions observed in epoch 850 range"
4274 );
4275}
4276
4277#[tokio::test(flavor = "multi_thread")]
4278async fn test_firehose_epoch_850_votes_present() {
4279 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4280 solana_logger::setup_with_default("info");
4281 const TARGET_SLOT: u64 = 367_200_100; const SLOT_RADIUS: u64 = 10;
4283 static SEEN_BLOCK: AtomicBool = AtomicBool::new(false);
4284 static VOTE_TXS: AtomicU64 = AtomicU64::new(0);
4285 static TOTAL_TXS: AtomicU64 = AtomicU64::new(0);
4286
4287 SEEN_BLOCK.store(false, Ordering::Relaxed);
4288 VOTE_TXS.store(0, Ordering::Relaxed);
4289 TOTAL_TXS.store(0, Ordering::Relaxed);
4290
4291 firehose(
4292 2,
4293 false,
4294 false,
4295 None,
4296 (TARGET_SLOT - SLOT_RADIUS)..(TARGET_SLOT + SLOT_RADIUS),
4297 Some(|_thread_id: usize, block: BlockData| {
4298 async move {
4299 if block.slot() == TARGET_SLOT {
4300 assert!(
4301 !block.was_skipped(),
4302 "target slot {TARGET_SLOT} was marked leader skipped",
4303 );
4304 SEEN_BLOCK.store(true, Ordering::Relaxed);
4305 }
4306 Ok(())
4307 }
4308 .boxed()
4309 }),
4310 Some(|_thread_id: usize, transaction: TransactionData| {
4311 async move {
4312 if transaction.slot == TARGET_SLOT {
4313 TOTAL_TXS.fetch_add(1, Ordering::Relaxed);
4314 if transaction.is_vote {
4315 VOTE_TXS.fetch_add(1, Ordering::Relaxed);
4316 }
4317 }
4318 Ok(())
4319 }
4320 .boxed()
4321 }),
4322 None::<OnEntryFn>,
4323 None::<OnRewardFn>,
4324 None::<OnErrorFn>,
4325 None::<OnStatsTrackingFn>,
4326 None,
4327 )
4328 .await
4329 .unwrap();
4330
4331 assert!(
4332 SEEN_BLOCK.load(Ordering::Relaxed),
4333 "target slot was not processed"
4334 );
4335 assert!(
4336 TOTAL_TXS.load(Ordering::Relaxed) > 0,
4337 "no transactions counted in target slot"
4338 );
4339 assert_eq!(VOTE_TXS.load(Ordering::Relaxed), 991);
4340}
4341
4342#[cfg(test)]
4343#[serial]
4344#[tokio::test(flavor = "multi_thread")]
4345async fn test_firehose_restart_loses_coverage_without_reset() {
4346 use std::collections::HashMap;
4347 solana_logger::setup_with_default("info");
4348 const THREADS: usize = 1;
4349 const START_SLOT: u64 = 345_600_000;
4350 const NUM_SLOTS: u64 = 8;
4351
4352 static COVERAGE: OnceLock<Mutex<HashMap<u64, u32>>> = OnceLock::new();
4353 COVERAGE
4354 .get_or_init(|| Mutex::new(HashMap::new()))
4355 .lock()
4356 .unwrap()
4357 .clear();
4358 static FAIL_TRIGGERED: AtomicBool = AtomicBool::new(false);
4359 static SEEN_BLOCKS: AtomicU64 = AtomicU64::new(0);
4360 FAIL_TRIGGERED.store(false, Ordering::Relaxed);
4361 SEEN_BLOCKS.store(0, Ordering::Relaxed);
4362
4363 firehose(
4364 THREADS.try_into().unwrap(),
4365 false,
4366 false,
4367 None,
4368 START_SLOT..(START_SLOT + NUM_SLOTS),
4369 Some(|_thread_id: usize, block: BlockData| {
4370 async move {
4371 if !block.was_skipped()
4373 && SEEN_BLOCKS.load(Ordering::Relaxed) > 0
4374 && !FAIL_TRIGGERED.swap(true, Ordering::SeqCst)
4375 {
4376 return Err("synthetic handler failure to exercise restart".into());
4377 }
4378 let mut coverage = COVERAGE
4379 .get_or_init(|| Mutex::new(HashMap::new()))
4380 .lock()
4381 .unwrap();
4382 *coverage.entry(block.slot()).or_insert(0) += 1;
4383 if !block.was_skipped() {
4384 SEEN_BLOCKS.fetch_add(1, Ordering::Relaxed);
4385 }
4386 Ok(())
4387 }
4388 .boxed()
4389 }),
4390 None::<OnTxFn>,
4391 None::<OnEntryFn>,
4392 None::<OnRewardFn>,
4393 None::<OnErrorFn>,
4394 None::<OnStatsTrackingFn>,
4395 None,
4396 )
4397 .await
4398 .unwrap();
4399
4400 let coverage = COVERAGE.get().unwrap().lock().unwrap();
4401 for slot in START_SLOT..(START_SLOT + NUM_SLOTS) {
4402 assert!(
4403 coverage.contains_key(&slot),
4404 "missing coverage for slot {slot} after restart"
4405 );
4406 }
4407}
4408
4409#[cfg(test)]
4410#[serial]
4411#[tokio::test(flavor = "multi_thread")]
4412async fn test_firehose_gap_coverage_near_known_missing_range() {
4413 use std::collections::HashSet;
4414 solana_logger::setup_with_default("info");
4415 const GAP_START: u64 = 378864000;
4416 const START_SLOT: u64 = GAP_START - 1000;
4417 const END_SLOT: u64 = GAP_START + 1000;
4418 const THREADS: usize = 16;
4419
4420 static COVERAGE: OnceLock<Mutex<HashSet<u64>>> = OnceLock::new();
4421 COVERAGE
4422 .get_or_init(|| Mutex::new(HashSet::new()))
4423 .lock()
4424 .unwrap()
4425 .clear();
4426
4427 firehose(
4428 THREADS.try_into().unwrap(),
4429 false,
4430 false,
4431 None,
4432 START_SLOT..(END_SLOT + 1),
4433 Some(|_thread_id: usize, block: BlockData| {
4434 async move {
4435 if block.was_skipped() {
4436 return Ok(());
4437 }
4438 let slot = block.slot();
4439 COVERAGE
4440 .get_or_init(|| Mutex::new(HashSet::new()))
4441 .lock()
4442 .unwrap()
4443 .insert(slot);
4444 Ok(())
4445 }
4446 .boxed()
4447 }),
4448 None::<OnTxFn>,
4449 None::<OnEntryFn>,
4450 None::<OnRewardFn>,
4451 None::<OnErrorFn>,
4452 None::<OnStatsTrackingFn>,
4453 None,
4454 )
4455 .await
4456 .unwrap();
4457
4458 let mut coverage = COVERAGE
4459 .get_or_init(|| Mutex::new(HashSet::new()))
4460 .lock()
4461 .unwrap()
4462 .clone();
4463
4464 coverage.insert(378864396);
4466 coverage.insert(378864397);
4467 coverage.insert(378864398);
4468 coverage.insert(378864399);
4469
4470 let expected: Vec<u64> = (START_SLOT..=END_SLOT).collect();
4471 let missing: Vec<u64> = expected
4472 .iter()
4473 .copied()
4474 .filter(|slot| !coverage.contains(slot))
4475 .collect();
4476 assert!(
4477 missing.is_empty(),
4478 "missing slots in {START_SLOT}..={END_SLOT}; count={}, first few={:?}",
4479 missing.len(),
4480 &missing[..missing.len().min(10)]
4481 );
4482}
4483
4484#[cfg(test)]
4485#[serial]
4486#[tokio::test(flavor = "multi_thread")]
4487async fn test_firehose_sequential_reverse_crosses_epoch_boundary() {
4488 use std::sync::{
4489 Arc, Mutex,
4490 atomic::{AtomicU64, Ordering},
4491 };
4492
4493 solana_logger::setup_with_default("info");
4494 const SLOT_COUNT: u64 = 100;
4495
4496 let (epoch_900_start, _) = epoch_to_slot_range(900);
4497 let slot_range = (epoch_900_start - SLOT_COUNT)..(epoch_900_start + SLOT_COUNT);
4498
4499 let observed_blocks: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::new()));
4500 let observed_tx_count = Arc::new(AtomicU64::new(0));
4501
4502 firehose(
4503 1,
4504 true,
4505 true,
4506 None,
4507 slot_range.clone(),
4508 Some({
4509 let observed_blocks = observed_blocks.clone();
4510 move |_thread_id: usize, block: BlockData| {
4511 let observed_blocks = observed_blocks.clone();
4512 async move {
4513 observed_blocks.lock().unwrap().push(block.slot());
4514 Ok(())
4515 }
4516 .boxed()
4517 }
4518 }),
4519 Some({
4520 let observed_tx_count = observed_tx_count.clone();
4521 move |_thread_id: usize, _tx: TransactionData| {
4522 let observed_tx_count = observed_tx_count.clone();
4523 async move {
4524 observed_tx_count.fetch_add(1, Ordering::Relaxed);
4525 Ok(())
4526 }
4527 .boxed()
4528 }
4529 }),
4530 None::<OnEntryFn>,
4531 None::<OnRewardFn>,
4532 None::<OnErrorFn>,
4533 None::<OnStatsTrackingFn>,
4534 None,
4535 )
4536 .await
4537 .unwrap();
4538
4539 let observed = observed_blocks.lock().unwrap().clone();
4540 assert!(
4541 !observed.is_empty(),
4542 "expected to observe at least one block"
4543 );
4544 assert!(
4545 observed_tx_count.load(Ordering::Relaxed) > 0,
4546 "expected to observe at least one transaction"
4547 );
4548
4549 let first_epoch = slot_to_epoch(observed[0]);
4551 assert_eq!(
4552 first_epoch, 900,
4553 "reverse mode must start with the highest epoch, got slot {} in epoch {}",
4554 observed[0], first_epoch,
4555 );
4556
4557 let mut transitions = 0u32;
4559 let mut current_epoch = first_epoch;
4560 let mut prev_slot_in_epoch: Option<u64> = None;
4561 for &slot in &observed {
4562 let epoch = slot_to_epoch(slot);
4563 if epoch != current_epoch {
4564 assert!(
4565 epoch < current_epoch,
4566 "epoch did not decrease across boundary: prev={current_epoch} now={epoch}",
4567 );
4568 transitions += 1;
4569 current_epoch = epoch;
4570 prev_slot_in_epoch = None;
4571 }
4572 if let Some(prev) = prev_slot_in_epoch {
4573 assert!(
4574 slot >= prev,
4575 "within epoch {epoch}, slot regressed: prev={prev} now={slot}",
4576 );
4577 }
4578 prev_slot_in_epoch = Some(slot);
4579 }
4580 assert_eq!(
4581 transitions, 1,
4582 "expected exactly one epoch transition for a range crossing one boundary",
4583 );
4584 assert_eq!(
4585 current_epoch, 899,
4586 "reverse mode should end at the lower epoch (899), got {current_epoch}",
4587 );
4588}
4589
4590#[cfg(test)]
4591#[serial]
4592#[tokio::test(flavor = "multi_thread")]
4593async fn test_firehose_reverse_implies_sequential() {
4594 use std::sync::{
4595 Arc, Mutex,
4596 atomic::{AtomicU64, Ordering},
4597 };
4598
4599 solana_logger::setup_with_default("info");
4600 const SLOT_COUNT: u64 = 100;
4601
4602 let (epoch_900_start, _) = epoch_to_slot_range(900);
4603 let slot_range = (epoch_900_start - SLOT_COUNT)..(epoch_900_start + SLOT_COUNT);
4604
4605 let observed_blocks: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::new()));
4606 let observed_tx_count = Arc::new(AtomicU64::new(0));
4607
4608 firehose(
4610 4,
4611 false,
4612 true,
4613 None,
4614 slot_range.clone(),
4615 Some({
4616 let observed_blocks = observed_blocks.clone();
4617 move |_thread_id: usize, block: BlockData| {
4618 let observed_blocks = observed_blocks.clone();
4619 async move {
4620 observed_blocks.lock().unwrap().push(block.slot());
4621 Ok(())
4622 }
4623 .boxed()
4624 }
4625 }),
4626 Some({
4627 let observed_tx_count = observed_tx_count.clone();
4628 move |_thread_id: usize, _tx: TransactionData| {
4629 let observed_tx_count = observed_tx_count.clone();
4630 async move {
4631 observed_tx_count.fetch_add(1, Ordering::Relaxed);
4632 Ok(())
4633 }
4634 .boxed()
4635 }
4636 }),
4637 None::<OnEntryFn>,
4638 None::<OnRewardFn>,
4639 None::<OnErrorFn>,
4640 None::<OnStatsTrackingFn>,
4641 None,
4642 )
4643 .await
4644 .unwrap();
4645
4646 let observed = observed_blocks.lock().unwrap().clone();
4647 assert!(
4648 !observed.is_empty(),
4649 "expected to observe at least one block"
4650 );
4651 assert_eq!(
4655 slot_to_epoch(observed[0]),
4656 900,
4657 "reverse should imply sequential and emit highest epoch first; first slot was {}",
4658 observed[0],
4659 );
4660}