Skip to main content

jetstreamer_firehose/
firehose.rs

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
51/// Timeout applied to each asynchronous firehose operation (fetching epoch stream, reading
52/// header, seeking, reading next block). Adjust here to tune stall detection/restart
53/// aggressiveness. Public so frontends (e.g. the TUI) can derive staleness thresholds from it.
54pub 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);
56// Backoff between restarts of a failed firehose thread. An immediate reconnect after a stall
57// tends to re-trigger the CDN throttling that caused it; repeated failures on the same slot
58// double the wait up to the cap, and any forward progress resets it.
59const 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);
61// Epochs earlier than this were bincode-encoded in Old Faithful.
62const 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
100/// Per-thread "data flowed" timestamps, stamped each time a firehose thread reads a full
101/// block. Drives the health-gated staggered launch and is available to frontends.
102pub 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    /// Milliseconds since tracking began (a process-wide monotonic clock).
124    pub fn now_ms() -> u64 {
125        ORIGIN.elapsed().as_millis() as u64
126    }
127
128    /// Clears stamps left over from a previous run.
129    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    /// Records a successful work steal.
142    pub fn note_steal() {
143        STEALS.fetch_add(1, Ordering::Relaxed);
144    }
145
146    /// Total work steals this run.
147    pub fn steal_count() -> u64 {
148        STEALS.load(Ordering::Relaxed)
149    }
150
151    /// Records a completed connection recycle.
152    pub fn note_recycle() {
153        RECYCLES.fetch_add(1, Ordering::Relaxed);
154    }
155
156    /// Total connection recycles this run.
157    pub fn recycle_count() -> u64 {
158        RECYCLES.load(Ordering::Relaxed)
159    }
160
161    /// Records an operation timeout (a stall that forced a restart).
162    pub fn note_timeout() {
163        TIMEOUTS.fetch_add(1, Ordering::Relaxed);
164    }
165
166    /// Total operation timeouts this run.
167    pub fn timeout_count() -> u64 {
168        TIMEOUTS.load(Ordering::Relaxed)
169    }
170
171    /// Adds processed transactions to `thread_index`'s cumulative count (recycle-rate input).
172    pub fn add_transactions(thread_index: usize, count: u64) {
173        *TX_COUNTS.entry(thread_index).or_insert(0) += count;
174    }
175
176    /// Cumulative transactions processed by `thread_index`.
177    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    /// Records that `thread_index` just (re)opened its stream.
185    pub fn note_stream_start(thread_index: usize) {
186        STREAM_START_MS.insert(thread_index, now_ms());
187    }
188
189    /// Milliseconds since `thread_index` last (re)opened its stream.
190    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    /// Asks `thread_index` to recycle its connection at the next block boundary.
197    pub fn request_recycle(thread_index: usize) {
198        RECYCLE_REQUESTED.insert(thread_index);
199    }
200
201    /// Consumes a pending recycle request for `thread_index`.
202    pub fn take_recycle(thread_index: usize) -> bool {
203        RECYCLE_REQUESTED.remove(&thread_index).is_some()
204    }
205
206    /// Records that `thread_index` completed its entire slot range. A finished thread stops
207    /// reading forever — without this marker its idle clock would make it look stalled.
208    pub fn note_finished(thread_index: usize) {
209        FINISHED.insert(thread_index);
210    }
211
212    /// Whether `thread_index` completed its slot range.
213    pub fn is_finished(thread_index: usize) -> bool {
214        FINISHED.contains(&thread_index)
215    }
216
217    /// Un-marks a finished thread that adopted stolen work and is running again.
218    pub fn clear_finished(thread_index: usize) {
219        FINISHED.remove(&thread_index);
220    }
221
222    /// Records that `thread_index` just read data.
223    pub fn note(thread_index: usize) {
224        LAST_ACTIVITY_MS.insert(thread_index, now_ms());
225    }
226
227    /// Milliseconds since `thread_index` last read data; `None` if it has not read any yet.
228    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
235/// Default launch-gate grace: how long to wait for every running thread to turn green before
236/// spawning the next one anyway. Overridden by `JETSTREAMER_SPAWN_GRACE_SECS`; `0` disables
237/// launch gating entirely.
238const SPAWN_GRACE_DEFAULT: std::time::Duration = std::time::Duration::from_secs(30);
239
240/// Recycle threshold as a percent of the fastest thread's current rate: threads persistently
241/// below it restart their connection to shed a throughput-clamped one. Override with
242/// `JETSTREAMER_RECYCLE_PCT`; `0` disables recycling.
243fn 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
251/// Maximum number of running-but-not-yet-green threads the launch gate allows before pausing
252/// the ramp. Override with `JETSTREAMER_SPAWN_PENDING`; `1` reproduces the strict
253/// one-at-a-time ramp.
254fn 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
273/// Launch gate for the staggered thread ramp: waits until every already-running thread has
274/// read data within the "green" window (10% of [`OP_TIMEOUT`], matching the TUI thread grid)
275/// so load is only added while the source is keeping up.
276///
277/// Up to [`spawn_pending_max`] not-yet-green threads may be in flight at once (a freshly
278/// spawned thread needs a few seconds to fetch its stream, seek, and read its first block —
279/// requiring strict all-green would serialize the ramp on that startup latency). `grace`
280/// bounds the wait for merely *sluggish* threads (yellow/orange in the TUI) so a flickering
281/// thread cannot stall the ramp forever — but a **red** thread (idle at or beyond the op
282/// timeout: stalled or backing off) holds the ramp outright, and the grace clock restarts
283/// whenever one is present. Spawning into visible distress only feeds the throttling that
284/// caused it. A requested shutdown releases the gate immediately (the spawned thread
285/// observes the shutdown flag and exits right away).
286async 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        // A thread that already completed its slot range stops reading forever; count it as
302        // healthy rather than letting it hold the ramp to the grace timeout.
303        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        // Red is checked before the pending window: red threads count as not-yet-green, and
322        // a couple of them must hold the ramp rather than slip under the window.
323        if any_red {
324            // Hold the ramp and restart the grace clock; only a red-free interval of `grace`
325            // can force a spawn past non-green threads.
326            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
349/// Shared per-thread work ledger used for work-steal victim selection. `start` is the
350/// beginning of the thread's current assignment (reset when it adopts stolen work), `next`
351/// is the next slot the owner will process (published at each block boundary), and `end` is
352/// the half-open end of the slice. Every field is written **only by its owning thread**;
353/// other threads read it purely as advisory telemetry when picking a steal victim. Actual
354/// splits happen over the steal message protocol (see [`StealRequest`]), never by writing to
355/// another thread's slice.
356struct WorkSlice {
357    start: AtomicU64,
358    next: AtomicU64,
359    end: AtomicU64,
360}
361
362/// Minimum remaining slots a slice must have to be worth splitting: half of this must cover
363/// the thief's reconnect + seek setup cost.
364const MIN_STEAL_SLOTS: u64 = 64;
365
366/// The active run's work ledger, stashed so out-of-band reporters (e.g. the fatal
367/// ClickHouse-abort path) can compute a safe resume point.
368static ACTIVE_WORK_LEDGER: std::sync::Mutex<Option<Arc<Vec<WorkSlice>>>> =
369    std::sync::Mutex::new(None);
370
371/// The lowest slot not yet fully processed by the active run, if one is running: everything
372/// below this is complete, so `resume_floor..original_end` is a safe (if conservative —
373/// higher threads' finished work above the floor gets re-read) resume range. Returns `None`
374/// when no run is active or every slice is complete.
375pub 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
389/// A work-steal proposal sent to a victim thread's steal inbox: "hand me half of your
390/// remaining work." The victim answers on `reply` with the granted range, or `None` when it
391/// has too little work left to split. The victim only services its inbox at quiescent points
392/// (between block batches, at restart boundaries, or while parked in backoff), so a grant
393/// can never race in-flight emission — the victim's answer *is* the authoritative split.
394struct StealRequest {
395    reply: oneshot::Sender<Option<Range<u64>>>,
396}
397
398/// Services a victim's steal inbox at a quiescent point. `position` is the victim's
399/// authoritative next-slot-to-process; `allow` is false when the victim is completing its
400/// range and only draining (all requests answered `None`). A grant is committed — the local
401/// range end shrinks and the ledger is updated — only if the reply is actually delivered, so
402/// an abandoned request can never orphan slots.
403fn 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
433/// Asks the least-progressed running thread for half of its remaining work, walking the
434/// candidate list until a victim grants. Candidates are ranked by lowest completed fraction
435/// of their current assignment (ties broken by most remaining), skipping threads that have
436/// not started streaming yet (they cannot answer their inbox) and threads without enough
437/// work to split.
438///
439/// Deadlock freedom: while awaiting a victim's answer, the thief keeps servicing its **own**
440/// inbox (rejecting — it has nothing to give), so every thread in every waiting state stays
441/// responsive. A request can only go permanently unanswered if the victim task exits, which
442/// drops its inbox and resolves the wait with an error. `lock` is held only around the
443/// scan-and-send (never across the reply await) to keep simultaneous thieves from bursting
444/// requests at the same victim; the victim re-validates every grant against its own
445/// authoritative position anyway.
446async 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                    // A thread that has not begun streaming cannot answer its inbox.
462                    && 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        // Await the victim's answer while staying responsive to our own inbox.
488        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                        // We are hunting because we have nothing left; refuse.
495                        Some(request) => {
496                            let _ = request.reply.send(None);
497                        }
498                        // Own inbox closed (shutdown teardown): just wait for the reply.
499                        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
517/// Decides how a reverse-mode retry resumes after an error attributed to `slot`, given that
518/// `last_counted_slot` was the last slot fully processed. Returns the new
519/// `(reverse_partial_resume, reverse_highest_remaining_epoch)`.
520///
521/// The subtlety: an error striking at an epoch slice's *tail* (after its final block was
522/// emitted, before the clean end-of-epoch break) is attributed to the next slot — which
523/// belongs to the next, **higher** epoch. Storing that as the partial-resume point poisons
524/// the retry: the epoch-match check sees a foreign epoch, discards the resume marker, and
525/// re-processes the entire slice from its start, double-emitting every slot in it. When the
526/// resume point crosses above the epoch that was actually being processed, the slice is
527/// complete — so mark the epoch done instead.
528fn 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        // Tail case: everything in `last_epoch` was processed. Only decrement the
542        // highest-remaining marker when it still points at that epoch (it may already point
543        // lower, e.g. when the error arrived before the first block of an earlier epoch).
544        let highest = if highest_remaining_epoch == Some(last_epoch) {
545            // `checked_sub` makes "no epochs remaining" explicit as `None` — required for
546            // epoch 0, where a saturating subtraction would silently stay at 0 and replay
547            // the epoch. Dropping below the range's lowest epoch also completes the run.
548            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
558/// Per-thread restart pacing: consecutive failures on the same slot double the delay up to
559/// [`RETRY_BACKOFF_MAX`]; a failure on a different slot means forward progress was made and
560/// resets the sequence.
561struct 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/// Errors that can occur while streaming the firehose. Errors that can occur while streaming
588/// the firehose.
589#[derive(Debug, Error)]
590pub enum FirehoseError {
591    /// HTTP client error surfaced from `reqwest`.
592    Reqwest(reqwest::Error),
593    /// Failure while reading the Old Faithful CAR header.
594    ReadHeader(SharedError),
595    /// Error emitted by the Solana Geyser plugin service.
596    GeyserPluginService(GeyserPluginServiceError),
597    /// Transaction notifier could not be acquired from the Geyser service.
598    FailedToGetTransactionNotifier,
599    /// Failure while reading data until the next block boundary.
600    ReadUntilBlockError(SharedError),
601    /// Failure while fetching an individual block.
602    GetBlockError(SharedError),
603    /// Failed to decode a node at the given index.
604    NodeDecodingError(usize, SharedError),
605    /// Error surfaced when querying the slot offset index.
606    SlotOffsetIndexError(SlotOffsetIndexError),
607    /// Failure while seeking to a slot within the Old Faithful CAR stream.
608    SeekToSlotError(SharedError),
609    /// Error surfaced during the plugin `on_load` stage.
610    OnLoadError(SharedError),
611    /// Error emitted while invoking the stats handler.
612    OnStatsHandlerError(SharedError),
613    /// Timeout reached while waiting for a firehose operation.
614    OperationTimeout(&'static str),
615    /// Deliberate connection recycle (not a failure): the thread restarts its stream to shed
616    /// a throughput-clamped connection.
617    ConnectionRecycled,
618    /// The thread's slot range is fully processed (not a failure): routed through the retry
619    /// loop so the thread can adopt stolen work or retire.
620    RangeComplete,
621    /// The HTTP stream ended (EOF) while the slot index proves present slots remain in the
622    /// thread's range — the CDN closed the connection mid-transfer. Retryable; without this
623    /// check a truncated stream is indistinguishable from a genuine end-of-epoch and the
624    /// remaining slots would be silently lost.
625    PrematureStreamEnd,
626    /// Transaction handler returned an error.
627    TransactionHandlerError(SharedError),
628    /// Entry handler returned an error.
629    EntryHandlerError(SharedError),
630    /// Reward handler returned an error.
631    RewardHandlerError(SharedError),
632    /// Block handler returned an error.
633    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/// Per-thread progress information emitted by the firehose runner.
728#[derive(Clone, PartialEq, Eq, Hash, Debug)]
729pub struct ThreadStats {
730    /// Identifier of the worker thread reporting the stats.
731    pub thread_id: usize,
732    /// Timestamp captured when the thread began processing.
733    pub start_time: std::time::Instant,
734    /// Timestamp captured when the thread finished, if finished.
735    pub finish_time: Option<std::time::Instant>,
736    /// Slot range currently assigned to the thread (half-open, may shrink on restart).
737    pub slot_range: Range<u64>,
738    /// Original slot range assigned to the thread (half-open, never modified).
739    pub initial_slot_range: Range<u64>,
740    /// Latest slot processed by the thread.
741    pub current_slot: u64,
742    /// Total slots processed by the thread.
743    pub slots_processed: u64,
744    /// Number of blocks successfully processed.
745    pub blocks_processed: u64,
746    /// Number of slots skipped by the cluster leader.
747    pub leader_skipped_slots: u64,
748    /// Total transactions processed.
749    pub transactions_processed: u64,
750    /// Total entries processed.
751    pub entries_processed: u64,
752    /// Total rewards processed.
753    pub rewards_processed: u64,
754}
755
756/// Aggregated firehose statistics covering all worker threads.
757#[derive(Clone, PartialEq, Eq, Hash, Debug)]
758pub struct Stats {
759    /// Per-thread statistics for the current update.
760    pub thread_stats: ThreadStats,
761    /// Timestamp captured when processing began.
762    pub start_time: std::time::Instant,
763    /// Timestamp captured when all processing finished, if finished.
764    pub finish_time: Option<std::time::Instant>,
765    /// Slot range currently being processed (half-open [start, end)).
766    pub slot_range: Range<u64>,
767    /// Aggregate slots processed across all threads.
768    pub slots_processed: u64,
769    /// Aggregate blocks processed across all threads.
770    pub blocks_processed: u64,
771    /// Aggregate skipped slots across all threads.
772    pub leader_skipped_slots: u64,
773    /// Aggregate transactions processed across all threads.
774    pub transactions_processed: u64,
775    /// Aggregate entries processed across all threads.
776    pub entries_processed: u64,
777    /// Aggregate rewards processed across all threads.
778    pub rewards_processed: u64,
779    /// Transactions processed since the previous stats pulse.
780    pub transactions_since_last_pulse: u64,
781    /// Blocks processed since the previous stats pulse.
782    pub blocks_since_last_pulse: u64,
783    /// Slots processed since the previous stats pulse.
784    pub slots_since_last_pulse: u64,
785    /// Elapsed time since the previous stats pulse.
786    pub time_since_last_pulse: std::time::Duration,
787}
788
789/// Configuration for periodic stats emission via a [`Handler`] callback.
790#[derive(Clone, PartialEq, Eq, Hash, Debug)]
791pub struct StatsTracking<OnStats: Handler<Stats>> {
792    /// Callback invoked whenever new stats are available.
793    pub on_stats: OnStats,
794    /// Emits a stats callback when the current slot is a multiple of this interval.
795    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        // Early epochs often omit metadata entirely.
881        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            // If the frame was not zstd-compressed (common for very early data), try to
894            // decode the raw bytes directly before bailing.
895            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        // Early epochs sometimes omit rewards payloads entirely.
925        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 we already tried bincode, surface both failures for easier debugging.
1006            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        // Epoch 100 should try bincode first; if those bytes are proto, we must fall back.
1091        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/// Firehose transaction payload passed to [`Handler`] callbacks.
1171#[derive(Debug, Clone)]
1172pub struct TransactionData {
1173    /// Slot that contains the transaction.
1174    pub slot: u64,
1175    /// Index of the transaction within the slot.
1176    pub transaction_slot_index: usize,
1177    /// Transaction signature.
1178    pub signature: solana_signature::Signature,
1179    /// Hash of the transaction message.
1180    pub message_hash: Hash,
1181    /// Indicates whether the transaction is a vote.
1182    pub is_vote: bool,
1183    /// Status metadata returned by the Solana runtime.
1184    pub transaction_status_meta: solana_transaction_status::TransactionStatusMeta,
1185    /// Fully decoded transaction.
1186    pub transaction: VersionedTransaction,
1187}
1188
1189/// Block entry metadata passed to [`Handler`] callbacks.
1190#[derive(Debug, Clone)]
1191pub struct EntryData {
1192    /// Slot that generated the entry.
1193    pub slot: u64,
1194    /// Index of the entry within the slot.
1195    pub entry_index: usize,
1196    /// Range of transaction indexes covered by the entry.
1197    pub transaction_indexes: Range<usize>,
1198    /// Number of hashes associated with the entry.
1199    pub num_hashes: u64,
1200    /// Entry hash.
1201    pub hash: Hash,
1202}
1203
1204/// Reward data conveyed to reward [`Handler`] callbacks.
1205#[derive(Debug, Clone)]
1206pub struct RewardsData {
1207    /// Slot the rewards correspond to.
1208    pub slot: u64,
1209    /// Reward recipients and their associated reward information.
1210    pub rewards: Vec<(Address, RewardInfo)>,
1211}
1212
1213/// Block-level data streamed to block handlers.
1214#[derive(Debug)]
1215pub enum BlockData {
1216    /// Fully populated block payload with ledger metadata.
1217    Block {
1218        /// Parent slot number.
1219        parent_slot: u64,
1220        /// Parent block hash.
1221        parent_blockhash: Hash,
1222        /// Current block slot.
1223        slot: u64,
1224        /// Current block hash.
1225        blockhash: Hash,
1226        /// Rewards keyed by account and partition information.
1227        rewards: KeyedRewardsAndNumPartitions,
1228        /// Optional Unix timestamp for the block.
1229        block_time: Option<i64>,
1230        /// Optional ledger block height.
1231        block_height: Option<u64>,
1232        /// Number of executed transactions in the block.
1233        executed_transaction_count: u64,
1234        /// Number of entries contained in the block.
1235        entry_count: u64,
1236    },
1237    /// Marker indicating the slot appears skipped (either truly skipped or it is late and will
1238    /// arrive out of order).
1239    PossibleLeaderSkipped {
1240        /// Slot number that either lacked a block or may still arrive later.
1241        slot: u64,
1242    },
1243}
1244
1245impl BlockData {
1246    /// Returns the slot associated with this block or skipped slot.
1247    #[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    /// Returns `true` if this record currently represents a missing/possibly skipped slot.
1256    #[inline(always)]
1257    pub const fn was_skipped(&self) -> bool {
1258        matches!(self, BlockData::PossibleLeaderSkipped { .. })
1259    }
1260
1261    /// Returns the optional block time when available.
1262    #[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
1274/// Asynchronous callback invoked for each firehose event of type `Data`.
1275pub 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
1282/// Function pointer alias for [`Handler`] callbacks.
1283pub type HandlerFn<Data> = fn(usize, Data) -> HandlerFuture;
1284/// Convenience alias for block handlers accepted by [`firehose`].
1285pub type OnBlockFn = HandlerFn<BlockData>;
1286/// Convenience alias for transaction handlers accepted by [`firehose`].
1287pub type OnTxFn = HandlerFn<TransactionData>;
1288/// Convenience alias for entry handlers accepted by [`firehose`].
1289pub type OnEntryFn = HandlerFn<EntryData>;
1290/// Convenience alias for reward handlers accepted by [`firehose`].
1291pub type OnRewardFn = HandlerFn<RewardsData>;
1292/// Type alias for [`StatsTracking`] using simple function pointers.
1293pub type StatsTracker = StatsTracking<HandlerFn<Stats>>;
1294/// Convenience alias for firehose error handlers.
1295pub type OnErrorFn = HandlerFn<FirehoseErrorContext>;
1296/// Convenience alias for stats tracking handlers accepted by [`firehose`].
1297pub type OnStatsTrackingFn = StatsTracking<HandlerFn<Stats>>;
1298
1299/// Metadata describing a firehose worker failure.
1300#[derive(Clone, Debug)]
1301pub struct FirehoseErrorContext {
1302    /// Thread index that encountered the error.
1303    pub thread_id: usize,
1304    /// Slot the worker was processing when the error surfaced.
1305    pub slot: u64,
1306    /// Epoch derived from the failing slot.
1307    pub epoch: u64,
1308    /// Stringified error payload for display/logging.
1309    pub error_message: String,
1310}
1311
1312/// Streams blocks, transactions, entries, rewards, and stats to user-provided handlers.
1313///
1314/// The requested `slot_range` is half-open `[start, end)`; on recoverable errors the
1315/// runner restarts from the last processed slot to maintain coverage.
1316///
1317/// When `sequential` is `true`, the firehose uses one worker thread and opens epoch streams
1318/// with ripget's parallel windowed downloader. In this mode `threads` configures ripget range
1319/// concurrency rather than firehose worker partitioning.
1320///
1321/// `buffer_window_bytes` controls the ripget hot/cold window when `sequential` is enabled.
1322/// Pass `None` to use the default (`min(4 GiB, 15% of available RAM)`).
1323///
1324/// When `reverse` is `true` (sequential mode only), epochs in the requested range are
1325/// processed from highest to lowest. Within each epoch slots are still emitted in ascending
1326/// order because the underlying CAR archive can only be streamed forward.
1327#[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    // Reverse mode implies sequential mode; activate it automatically when caller passed
1361    // `reverse: true` without `sequential: true`.
1362    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    // divide slot_range into n subranges
1387    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    // Build a shared ripget HTTP client so TCP connections survive across epoch transitions.
1406    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    // Shared per-thread error counters
1420    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    // Connection-recycle monitor: Cloudflare clamps long-lived connections while fresh ones
1438    // get full burst throughput, and a clamped-but-flowing thread stays green forever, so
1439    // health checks alone never rotate it. Every sweep, threads running well below the
1440    // fastest thread's rate are asked to reconnect (a clean restart with no backoff).
1441    let recycle_pct = recycle_threshold_pct();
1442    let thread_total = subranges.len();
1443    // Work-stealing ledger (owner-written telemetry for victim selection) plus one steal
1444    // inbox per thread for the split protocol itself.
1445    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    // Coverage journal: every completed assignment records the interval it actually
1466    // processed, and the end-of-run audit verifies the union covers the requested range.
1467    // This turns any silent slot loss (whatever the cause) into a loud, precise error.
1468    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    // Always on in threaded forward mode; sequential/reverse runs and single-thread runs
1474    // have nothing to steal.
1475    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            /// Rolling rotation, not a storm: a uniform clamp puts most of the fleet under
1488            /// the threshold at once, and recycling everyone simultaneously would zero
1489            /// throughput. Rotating the worst few per sweep cycles the whole fleet through
1490            /// fresh connections within a few minutes while the rest keep streaming.
1491            const MAX_RECYCLES_PER_SWEEP: usize = 16;
1492            let mut prev_counts: Vec<u64> = vec![0; thread_total];
1493            let mut primed = false;
1494            // Benchmark rate: the best 90th-percentile sweep rate observed this run. Using a
1495            // percentile means ~a tenth of the fleet must sustain a rate before it becomes
1496            // the bar — one anomalously fast thread can't set it.
1497            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                // The first sweep only seeds the per-thread snapshots.
1510                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                // Worst offenders first, capped per sweep.
1523                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                            // Give new connections time to prove themselves first.
1531                            && 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            // Reverse-mode state preserved across retries. `None` for the highest remaining
1622            // epoch explicitly means "every epoch is complete" — required so completing
1623            // epoch 0 is distinguishable from epoch 0 still pending.
1624            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            // let mut triggered = false;
1651            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                // Each pass through this block opens a fresh stream; the stamp shields young
1659                // connections from the recycle monitor while they warm up.
1660                thread_activity::note_stream_start(thread_index);
1661                // Restart boundary is quiescent: answer any steal proposals that arrived
1662                // while the previous pass was ending.
1663                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                // for each epoch
1697                let mut current_slot: Option<u64> = None;
1698                let epoch_iter: Vec<u64> = if reverse_mode_local {
1699                    // All epochs already completed across previous retries?
1700                    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                    // Reset counters to align to the local epoch slice; prevents boundary slots
1790                    // from being treated as already-counted after a restart.
1791                    last_counted_slot = local_start.saturating_sub(1);
1792                    current_slot = None;
1793                    if reverse_mode_local {
1794                        // In reverse mode each epoch is processed forward independently;
1795                        // the cross-epoch monotonic dedup check would otherwise reject every
1796                        // slot below the previously processed (higher) epoch's range.
1797                        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                        // Seek to the start of `local_start`'s data; the index maps each slot to
1807                        // the byte range containing all of its nodes (transactions, entries,
1808                        // rewards, block), and the seek skips forward over missing slots. Errors
1809                        // are attributed to `local_start` so retries invalidate and resume the
1810                        // epoch actually being sought. Acquire the global seek-spacing permit
1811                        // before starting the timeout clock: with hundreds of threads the permit
1812                        // queue alone can exceed the op timeout, and that wait is pacing, not a
1813                        // stall.
1814                        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                    // for each item in each block
1828                    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                        // Quiescent point: no emission is in flight between batches, so this
1870                        // is where steal proposals are answered. A grant shrinks
1871                        // `slot_range.end`, and the `slot >= slot_range.end` guard below
1872                        // completes the range before any out-of-range data is emitted.
1873                        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                            // EOF is ambiguous: it can mean the genuine end of the epoch's
1890                            // data, or a connection the CDN closed mid-transfer. Consult the
1891                            // slot index: if any present slot remains in this thread's slice
1892                            // of the epoch, the stream was truncated and completing here
1893                            // would silently drop those slots.
1894                            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                            // We use >= because slot_range is half-open [start, end), so any
1940                            // slot equal to end is out-of-range and must not be processed. Do
1941                            // not emit synthetic skipped slots here; another thread may own the
1942                            // boundary. In reverse mode we still have lower epochs to process,
1943                            // so just break out of this epoch's inner loop.
1944                            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                            // On completion, report threads with non-zero error counts for
2435                            // visibility.
2436                            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                        // Mark this epoch as fully processed so retries skip it
2457                        // (`checked_sub` yields `None` at epoch 0: nothing remains).
2458                        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                        // Do not synthesize skipped slots during final flush; another thread may
2472                        // cover the remaining range (especially across epoch boundaries).
2473                    }
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                // Range completion arrives through the retry loop so the thread can adopt
2510                // stolen work (restarting the loop with a new range) or retire.
2511                if matches!(err, FirehoseError::RangeComplete) {
2512                    // Journal the interval this assignment actually processed (read before
2513                    // a steal adoption overwrites the ledger entry). Trailing slots between
2514                    // last_counted and the assignment end are left to the audit, which
2515                    // verifies such gaps contain no present slots.
2516                    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                    // This thread is done with its own range: publish "nothing remaining" so
2525                    // hunters stop targeting it, then drain any pending steal proposals with
2526                    // a refusal before going hunting itself.
2527                    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                // A deliberate connection recycle is a clean restart, not a failure: skip the
2576                // error logging, error counter, on_error callback, and retry backoff.
2577                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                    // Clear cached index data for this epoch to avoid retrying with a bad/partial index
2608                    // (or a bad seek offset that landed mid-stream).
2609                    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                    // Increment this thread's error counter
2628                    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                // Update slot range to resume from the failed slot, not the original start.
2637                // Reset local tracking so we don't treat the resumed slot range as already counted.
2638                // If we've already counted this slot, resume from the next one to avoid duplicates.
2639                if reverse_mode_local {
2640                    // In reverse mode, completed higher epochs are tracked via
2641                    // reverse_highest_remaining_epoch and the within-epoch resume slot lives in
2642                    // reverse_partial_resume; slot_range stays at its original bounds.
2643                    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                // Reset pulse timer to exclude downtime from next rate calc.
2656                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                        // initial_slot_range remains unchanged for progress reporting.
2662                    }
2663                if block_enabled {
2664                    pending_skipped_slots.remove(&thread_index);
2665                }
2666                // `skip_until_index` is unsafe across retries because `item_index`
2667                // is reset to 0 each epoch restart. Keeping it can skip large portions
2668                // of the stream and silently drop slots.
2669                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                    // Sleep in slices so the thread stays responsive to shutdown (and to
2679                    // steal proposals — a backing-off thread is quiescent and often the
2680                    // least-progressed, so it is a prime steal victim) during the wait.
2681                    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    // Wait for all threads to complete
2716    for handle in handles {
2717        handle.await.unwrap();
2718    }
2719    if let Some(monitor) = recycle_monitor {
2720        monitor.abort();
2721    }
2722    // End-of-run coverage audit: the union of journaled intervals must cover every present
2723    // slot in the requested range. Skipped when the run was interrupted (holes are expected
2724    // then) or when work stealing (and thus journaling) was inactive.
2725    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)]
2812/// Builds a Geyser-backed firehose and returns a slot notification stream.
2813///
2814/// This helper is used by [`firehose`] when Geyser plugins need to be stood up in-process
2815/// rather than relying solely on remote streams. The provided `slot_range` is treated as a
2816/// half-open interval `[start, end)`, and the thread will restart from the last processed
2817/// slot on recoverable errors to maintain coverage.
2818pub 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    // divide slot_range into n subranges
2878    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    // Shared per-thread error counters
2885    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    // Wait for all threads to complete
2918    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    // let mut triggered = false;
2964    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            // for each epoch
2978            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                // Reset counters to align to the local epoch slice; prevents boundary slots
3018                // from being treated as already-counted after a restart.
3019                last_counted_slot = local_start.saturating_sub(1);
3020                current_slot = None;
3021
3022                if local_start > epoch_start {
3023                    // Seek to the start of `local_start`'s data; the index maps each slot to
3024                    // the byte range containing all of its nodes (transactions, entries,
3025                    // rewards, block), and the seek skips forward over missing slots. Errors
3026                    // are attributed to `local_start` so retries invalidate and resume the
3027                    // epoch actually being sought. Acquire the global seek-spacing permit
3028                    // before starting the timeout clock: with hundreds of threads the permit
3029                    // queue alone can exceed the op timeout, and that wait is pacing, not a
3030                    // stall.
3031                    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                // for each item in each block
3045                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                        // EOF is ambiguous (genuine epoch end vs a connection the CDN closed
3071                        // mid-transfer); consult the slot index before completing.
3072                        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 early to terminate the firehose thread cleanly. We use >=
3114                        // because slot_range is half-open [start, end), so any slot equal to
3115                        // end is out-of-range and must not be processed.
3116                        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 item_index == 100000 && !triggered { log::info!("simulating
3155                        //     error"); triggered = true; return
3156                        //     Err(Box::new(GeyserReplayError::NodeDecodingError(item_index,
3157                        //     Box::new(std::io::Error::new( std::io::ErrorKind::Other,
3158                        //         "simulated error", )), ))); }
3159                        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                                        // Signature verification is optional because it is
3201                                        // extremely expensive at replay scale.
3202                                        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                        // On completion, report threads with non-zero error counts for
3325                        // visibility.
3326                        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                // Clear cached index data for this epoch to avoid retrying with a bad/partial index
3366                // (or a bad seek offset that landed mid-stream).
3367                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            // Increment this thread's error counter
3374            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            // Update slot range to resume from the failed slot, not the original start.
3383            // If the failing slot was already fully processed, resume from the next slot.
3384            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` is unsafe across retries because `item_index`
3390            // is reset to 0 each epoch restart. Keeping it can skip large portions
3391            // of the stream and silently drop slots.
3392            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]
3464/// Splits `slot_range` into nearly-even sub-ranges for the given thread count.
3465pub 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            // Distribute remainder slots to the first `remainder` threads
3473            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    // Verify that ranges cover all slots exactly
3481    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    // Verify no gaps between ranges
3489    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    // Epoch 899 spans slots 388368000..=388799999; epoch 900 starts at 388800000.
3561
3562    #[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        // Error attributed to the next epoch's first slot after the tail slot was counted:
3572        // the epoch slice is done; resuming from the slice start would double-emit it.
3573        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        // Decoding error attributed to the already-counted tail slot: resume would be
3581        // tail + 1, crossing the boundary — same completion case.
3582        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        // First epoch (900) seek fails before any block; last_counted is still the
3590        // pre-range sentinel in epoch 899. The higher epoch must not be marked complete.
3591        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        // Epoch 900 finished (last_counted in 900); epoch 899's seek fails with the error
3599        // attributed inside 899. Not a tail crossing — keep a resume marker (which the
3600        // epoch-match check resolves to the slice start of 899).
3601        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        // Epoch 0's tail is slot 431999; the error is attributed to slot 432000 (epoch 1).
3609        // "No epochs remaining" must be explicit (`None`) — a saturating subtraction would
3610        // silently pin at 0 and replay epoch 0 forever.
3611        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        // The thief gave up before the victim answered: the grant must not commit,
3675        // otherwise the granted slots would be orphaned.
3676        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                    /*log::info!(
3962                        target: LOG_MODULE,
3963                        "got block {} on thread {}",
3964                        block.slot(),
3965                        thread_id,
3966                    );*/
3967                }
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    // 345600000 is present but empty; still emitted as a block. Skip set should not include it.
4019    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                    // Old Faithful does not include leader-skipped slots, so gaps are
4153                    // expected. We only enforce monotonic (non-decreasing) tx slot ordering.
4154                    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; // within epoch 850
4239    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; // epoch 850
4282    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                // Force an error after at least one block has been seen so restart happens mid-range.
4372                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    // ignore a known 4-slot leader skipped gap
4465    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    // First observed slot must be in the higher epoch (900).
4550    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    // Verify within-epoch ascending order and exactly one epoch decrease.
4558    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    // sequential = false, reverse = true: firehose should auto-activate sequential mode.
4609    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    // If sequential were ignored, multiple firehose threads would interleave epochs and the
4652    // first-observed slot is unlikely to be in epoch 900. The reverse-implies-sequential
4653    // contract requires the first observed slot to be in the highest epoch.
4654    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}