Skip to main content

yellowstone_fumarole_client/core/
runtime.rs

1#[cfg(feature = "prometheus")]
2use crate::{
3    FumaroleClient,
4    metrics::{
5        dec_inflight_slot_download, inc_offset_commitment_count, inc_skip_offset_commitment_count,
6        inc_slot_download_count, inc_slot_status_offset_processed_count,
7        inc_total_event_downloaded, observe_slot_download_duration, set_max_slot_detected,
8        set_processed_slot_status_offset_queue_len, set_slot_status_update_queue_len,
9    },
10};
11use {
12    super::{
13        ports::{ControlPlaneConnector, ControlPlaneStreamError, FumaroleDataplaneConnector},
14        state_machine::{FumaroleSM, FumeDownloadRequest, FumeOffset, FumeShardIdx},
15    },
16    crate::{
17        error::FumaroleSubscribeError,
18        proto::{
19            self, BlockFilters, CommitOffset, ControlCommand, DataCommand, DataResponse,
20            DownloadBlockShard, GetChainTipResponse, JoinControlPlane, PollBlockchainHistory,
21            control_response::Response, data_command::Command, data_response,
22        },
23    },
24    crossbeam::queue::SegQueue,
25    futures::{Sink, SinkExt, Stream, StreamExt},
26    rustc_hash::FxHashSet,
27    solana_clock::Slot,
28    std::{
29        collections::{HashMap, VecDeque},
30        sync::Arc,
31        time::{Duration, Instant},
32    },
33    tokio::{
34        sync::mpsc::{self, error::TrySendError},
35        task::{self, Id, JoinSet},
36    },
37    tokio_util::sync::PollSender,
38    yellowstone_grpc_proto::geyser::{
39        self, CommitmentLevel, SubscribeRequest, SubscribeUpdate, SubscribeUpdateSlot,
40        subscribe_update::UpdateOneof,
41    },
42};
43
44pub const DEFAULT_GC_INTERVAL: usize = 100;
45
46pub struct FumaroleRuntimeDataEvent {
47    pub slot: Slot,
48    pub update: SubscribeUpdate,
49}
50
51pub struct FumaroleRuntimeCommitEvent {
52    /// We put this inside an Option to make sure the detect if user dropped the event without processing it, which should not happen but just in case.
53    sequence: Option<u64>,
54}
55
56impl Drop for FumaroleRuntimeCommitEvent {
57    fn drop(&mut self) {
58        if self.sequence.is_some() {
59            tracing::error!(
60                "FumaroleRuntimeCommitEvent dropped without being processed, sequence: {:?}",
61                self.sequence
62            );
63        }
64    }
65}
66
67#[allow(clippy::large_enum_variant)]
68pub enum FumaroleRuntimeEvent {
69    Data(FumaroleRuntimeDataEvent),
70    Committable(FumaroleRuntimeCommitEvent),
71    SlotEnded(u64),
72}
73
74///
75/// Mimics Dragonsmouth subscribe request bidirectional stream.
76///
77pub struct DragonsmouthSubscribeRequestBidi {
78    #[allow(dead_code)]
79    pub tx: mpsc::Sender<SubscribeRequest>,
80    pub rx: mpsc::Receiver<SubscribeRequest>,
81}
82
83pub enum BackgroundJobResult {
84    #[allow(dead_code)]
85    UpdateTip(GetChainTipResponse),
86}
87
88///
89/// Fumarole runtime based on Tokio outputting Dragonsmouth only events.
90///
91/// Drives the Fumarole State-Machine ([`FumaroleSM`]) using Async I/O.
92///
93pub(crate) struct FumaroleAsyncRuntime<C>
94where
95    C: ControlPlaneConnector,
96{
97    pub sm: FumaroleSM,
98    #[allow(dead_code)]
99    pub blockchain_id: Vec<u8>,
100    #[cfg(feature = "prometheus")]
101    pub fumarole_client: FumaroleClient,
102    pub download_task_runner_chans: DownloadTaskRunnerChannels,
103    pub dragonsmouth_bidi: DragonsmouthSubscribeRequestBidi,
104    pub subscribe_request: Arc<SubscribeRequest>,
105    pub persistent_subscriber_name: String,
106    pub control_plane_connector: C,
107    pub control_plane_tx: C::ControlPlaneSink,
108    pub control_plane_rx: C::ControlPlaneStream,
109    pub outlet: mpsc::Sender<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
110    pub commit_interval: Duration,
111    pub get_tip_interval: Duration,
112    pub last_commit: Instant,
113    pub last_tip: Instant,
114    pub last_history_poll: Option<Instant>,
115    pub gc_interval: usize, // in ticks
116    pub non_critical_background_jobs: JoinSet<BackgroundJobResult>,
117    pub download_task_runner_jh: tokio::task::JoinHandle<Result<(), DownloadBlockError>>,
118    pub no_commit: bool,
119    pub stop: bool,
120    pub shared_commit_offset_queue: Arc<SegQueue<FumaroleRuntimeCommitEvent>>,
121}
122
123const DEFAULT_HISTORY_POLL_SIZE: i64 = 100;
124const CONTROL_PLANE_REJOIN_MAX_ATTEMPTS: usize = 3;
125const CONTROL_PLANE_REJOIN_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10);
126const CONTROL_PLANE_REJOIN_BACKOFF: Duration = Duration::from_secs(2);
127
128const fn build_poll_history_cmd(from: Option<FumeOffset>) -> ControlCommand {
129    ControlCommand {
130        command: Some(proto::control_command::Command::PollHist(
131            // from None means poll the entire history from wherever we left off since last commit.
132            PollBlockchainHistory {
133                shard_id: 0, /*ALWAYS 0-FOR FIRST VERSION OF FUMAROLE */
134                from,
135                limit: Some(DEFAULT_HISTORY_POLL_SIZE),
136            },
137        )),
138    }
139}
140
141const fn build_commit_offset_cmd(offset: FumeOffset) -> ControlCommand {
142    ControlCommand {
143        command: Some(proto::control_command::Command::CommitOffset(
144            CommitOffset {
145                offset,
146                shard_id: 0, /*ALWAYS 0-FOR FIRST VERSION OF FUMAROLE */
147            },
148        )),
149    }
150}
151
152impl From<SubscribeRequest> for BlockFilters {
153    fn from(val: SubscribeRequest) -> Self {
154        BlockFilters {
155            accounts: val.accounts,
156            transactions: val.transactions,
157            entries: val.entry,
158            blocks_meta: val.blocks_meta,
159        }
160    }
161}
162
163enum LoopInstruction {
164    Continue,
165    ErrorStop,
166}
167
168impl<C> FumaroleAsyncRuntime<C>
169where
170    C: ControlPlaneConnector,
171{
172    fn handle_control_response(&mut self, control_response: proto::ControlResponse) {
173        let Some(response) = control_response.response else {
174            return;
175        };
176        match response {
177            proto::control_response::Response::CommitOffset(commit_offset_result) => {
178                tracing::debug!("received commit offset : {commit_offset_result:?}");
179                self.sm.update_committed_offset(commit_offset_result.offset);
180            }
181            proto::control_response::Response::PollHist(blockchain_history) => {
182                self.last_history_poll = None;
183                if !blockchain_history.events.is_empty() {
184                    tracing::debug!(
185                        "polled blockchain history : {} events",
186                        blockchain_history.events.len()
187                    );
188                }
189
190                self.sm.queue_blockchain_event(blockchain_history.events);
191                #[cfg(feature = "prometheus")]
192                {
193                    set_max_slot_detected(self.sm.max_slot_detected);
194                }
195            }
196            proto::control_response::Response::Pong(_pong) => {
197                tracing::debug!("pong");
198            }
199            proto::control_response::Response::Init(_init) => {
200                unreachable!("init should not be received here");
201            }
202        }
203    }
204
205    async fn poll_history_if_needed(&mut self) {
206        if self.last_history_poll.is_none() && self.sm.need_new_blockchain_events() {
207            #[cfg(feature = "prometheus")]
208            {
209                use crate::metrics::inc_poll_history_call_count;
210                inc_poll_history_call_count();
211            }
212            tracing::trace!(
213                "polling blockchain history from offset {}",
214                self.sm.unprocessed_blockchain_event.len()
215            );
216            let cmd = build_poll_history_cmd(Some(self.sm.committable_offset));
217            if self.control_plane_tx.send(cmd).await.is_err() {
218                panic!("control plane disconnected");
219            }
220            self.last_history_poll = Some(Instant::now());
221        }
222    }
223
224    fn commitment_level(&self) -> Option<geyser::CommitmentLevel> {
225        self.subscribe_request
226            .commitment
227            .map(|cl| CommitmentLevel::try_from(cl).expect("invalid commitment level"))
228    }
229
230    async fn schedule_download_task_if_any(&mut self) {
231        // This loop drains as many download slot request as possible,
232        // limited to available [`DataPlaneBidi`].
233        loop {
234            let result = self
235                .download_task_runner_chans
236                .download_task_queue_tx
237                .try_reserve();
238            let permit = match result {
239                Ok(permit) => permit,
240                Err(TrySendError::Full(_)) => {
241                    #[cfg(feature = "prometheus")]
242                    {
243                        use crate::metrics::incr_download_queue_full_detection_count;
244                        incr_download_queue_full_detection_count();
245                    }
246                    break;
247                }
248                Err(TrySendError::Closed(_)) => {
249                    panic!("download task runner closed unexpectedly")
250                }
251            };
252
253            let Some(download_request) = self.sm.pop_slot_to_download(self.commitment_level())
254            else {
255                break;
256            };
257            tracing::debug!(slot = download_request.slot, "scheduling download task");
258            let download_task_args = DownloadTaskArgs { download_request };
259            permit.send(download_task_args);
260        }
261    }
262
263    async fn handle_download_result(&mut self, completed: CompletedDownloadBlockTask) {
264        let CompletedDownloadBlockTask {
265            slot,
266            block_uid: _,
267
268            shard_idx_vec,
269        } = completed;
270        for shard_idx in shard_idx_vec {
271            self.sm.make_slot_download_progress(slot, Some(shard_idx));
272        }
273    }
274
275    async unsafe fn force_commit_offset(&mut self) {
276        if self.no_commit {
277            tracing::debug!("no_commit is set, skipping offset commitment");
278            self.sm.update_committed_offset(self.sm.committable_offset);
279            return;
280        }
281        self.control_plane_tx
282            .send(build_commit_offset_cmd(self.sm.committable_offset))
283            .await
284            .unwrap_or_else(|_| panic!("failed to commit offset"));
285        #[cfg(feature = "prometheus")]
286        {
287            use crate::metrics::set_max_offset_committed;
288
289            inc_offset_commitment_count();
290            set_max_offset_committed(self.sm.committable_offset);
291        }
292    }
293
294    async fn commit_offset(&mut self) {
295        if self.sm.last_committed_offset < self.sm.committable_offset {
296            unsafe {
297                self.force_commit_offset().await;
298            }
299        } else {
300            #[cfg(feature = "prometheus")]
301            {
302                inc_skip_offset_commitment_count();
303            }
304        }
305
306        self.last_commit = Instant::now();
307    }
308
309    async fn drain_slot_status(&mut self) {
310        let commitment = self.subscribe_request.commitment();
311        let mut slot_status_vec = VecDeque::with_capacity(10);
312
313        while let Some(slot_status) = self.sm.pop_next_slot_status() {
314            slot_status_vec.push_back(slot_status);
315        }
316
317        if slot_status_vec.is_empty() {
318            return;
319        }
320
321        for slot_status in slot_status_vec {
322            let mut matched_filters = vec![];
323            for (filter_name, filter) in &self.subscribe_request.slots {
324                if let Some(true) = filter.filter_by_commitment {
325                    if slot_status.commitment_level == commitment {
326                        matched_filters.push(filter_name.clone());
327                    }
328                } else {
329                    matched_filters.push(filter_name.clone());
330                }
331            }
332
333            if !matched_filters.is_empty() {
334                let update = SubscribeUpdate {
335                    filters: matched_filters,
336                    created_at: None,
337                    update_oneof: Some(geyser::subscribe_update::UpdateOneof::Slot(
338                        SubscribeUpdateSlot {
339                            slot: slot_status.slot,
340                            parent: slot_status.parent_slot,
341                            status: slot_status.commitment_level.into(),
342                            dead_error: slot_status.dead_error,
343                        },
344                    )),
345                };
346                if self
347                    .outlet
348                    .send(Ok(FumaroleRuntimeEvent::Data(FumaroleRuntimeDataEvent {
349                        slot: slot_status.slot,
350                        update,
351                    })))
352                    .await
353                    .is_err()
354                {
355                    return;
356                }
357            }
358            if self
359                .outlet
360                .send(Ok(FumaroleRuntimeEvent::Committable(
361                    FumaroleRuntimeCommitEvent {
362                        sequence: Some(slot_status.session_sequence),
363                    },
364                )))
365                .await
366                .is_err()
367            {
368                return;
369            }
370            #[cfg(feature = "prometheus")]
371            {
372                inc_slot_status_offset_processed_count();
373            }
374        }
375
376        #[cfg(feature = "prometheus")]
377        {
378            set_processed_slot_status_offset_queue_len(self.sm.processed_offset_queue_len());
379        }
380    }
381
382    async fn rejoin_controle_plane(&mut self) -> Result<(), C::SubscribeError> {
383        self.last_history_poll = None;
384        let initial_join = JoinControlPlane {
385            consumer_group_name: Some(self.persistent_subscriber_name.clone()),
386        };
387        let (control_plane_tx, mut control_plane_rx) =
388            self.control_plane_connector.subscribe(initial_join).await?;
389        let initial_response = control_plane_rx
390            .next()
391            .await
392            .expect("control plane closed before init")
393            .expect("control plane init error");
394        let response = initial_response.response.expect("none");
395        let Response::Init(initial_state) = response else {
396            panic!("unexpected initial response: {response:?}")
397        };
398        self.control_plane_tx = control_plane_tx;
399        self.control_plane_rx = control_plane_rx;
400        tracing::info!("rejoined control plane with initial state: {initial_state:?}");
401        Ok(())
402    }
403
404    async fn handle_control_plane_resp(
405        &mut self,
406        result: Result<proto::ControlResponse, ControlPlaneStreamError>,
407    ) -> LoopInstruction {
408        match result {
409            Ok(control_response) => {
410                self.handle_control_response(control_response);
411                LoopInstruction::Continue
412            }
413            Err(ControlPlaneStreamError::Disconnected(e)) => {
414                tracing::warn!(
415                    "control plane connection lost with error: {e:?}, attempting to rejoin..."
416                );
417                for attempt in 1..=CONTROL_PLANE_REJOIN_MAX_ATTEMPTS {
418                    tracing::warn!(
419                        "control plane rejoin attempt {attempt}/{CONTROL_PLANE_REJOIN_MAX_ATTEMPTS}"
420                    );
421
422                    match tokio::time::timeout(
423                        CONTROL_PLANE_REJOIN_ATTEMPT_TIMEOUT,
424                        self.rejoin_controle_plane(),
425                    )
426                    .await
427                    {
428                        Ok(Ok(_)) => {
429                            tracing::info!(
430                                "control plane rejoin succeeded on attempt {attempt}/{CONTROL_PLANE_REJOIN_MAX_ATTEMPTS}"
431                            );
432                            return LoopInstruction::Continue;
433                        }
434                        Ok(Err(rejoin_err)) => {
435                            tracing::warn!(
436                                "control plane rejoin attempt {attempt}/{CONTROL_PLANE_REJOIN_MAX_ATTEMPTS} failed: {rejoin_err:?}"
437                            );
438                        }
439                        Err(_) => {
440                            tracing::warn!(
441                                "control plane rejoin attempt {attempt}/{CONTROL_PLANE_REJOIN_MAX_ATTEMPTS} timed out after {:?}",
442                                CONTROL_PLANE_REJOIN_ATTEMPT_TIMEOUT
443                            );
444                        }
445                    }
446
447                    if attempt < CONTROL_PLANE_REJOIN_MAX_ATTEMPTS {
448                        tokio::time::sleep(CONTROL_PLANE_REJOIN_BACKOFF).await;
449                    }
450                }
451
452                tracing::error!(
453                    "exhausted control plane rejoin attempts ({CONTROL_PLANE_REJOIN_MAX_ATTEMPTS}) after disconnect: {e:?}"
454                );
455                let _ = self
456                    .outlet
457                    .send(Err(FumaroleSubscribeError::ControlPlaneRejoinFailed {
458                        details: Some(Box::new(std::io::Error::other(format!(
459                            "exhausted control plane rejoin attempts ({CONTROL_PLANE_REJOIN_MAX_ATTEMPTS})"
460                        )))),
461                    }))
462                    .await;
463                LoopInstruction::ErrorStop
464            }
465            Err(ControlPlaneStreamError::ApplicationError(e)) => {
466                tracing::error!("control plane application error: {e:?}");
467                let _ = self
468                    .outlet
469                    .send(Err(FumaroleSubscribeError::ControlPlaneDisconnected))
470                    .await;
471                LoopInstruction::ErrorStop
472            }
473        }
474    }
475
476    async fn handle_new_subscribe_request(&mut self, subscribe_request: SubscribeRequest) {
477        self.subscribe_request = Arc::new(subscribe_request);
478        self.download_task_runner_chans
479            .cnc_tx
480            .send(DownloadTaskRunnerCommand::UpdateSubscribeRequest(
481                Arc::clone(&self.subscribe_request),
482            ))
483            .await
484            .expect("failed to send subscribe request");
485    }
486
487    async fn update_tip(&mut self) {
488        #[cfg(feature = "prometheus")]
489        {
490            use crate::proto::GetChainTipRequest;
491
492            let mut fumarole_client = self.fumarole_client.clone();
493            let blockchain_id = self.blockchain_id.clone();
494            let job = async move {
495                let result = fumarole_client
496                    .get_chain_tip(GetChainTipRequest { blockchain_id })
497                    .await
498                    .expect("failed to get chain tip")
499                    .into_inner();
500                BackgroundJobResult::UpdateTip(result)
501            };
502
503            self.non_critical_background_jobs.spawn(job);
504        }
505        self.last_tip = Instant::now();
506    }
507
508    fn handle_non_critical_job_result(&mut self, result: BackgroundJobResult) {
509        match result {
510            BackgroundJobResult::UpdateTip(get_tip_response) => {
511                tracing::debug!(
512                    last_committed_offset = self.sm.last_committed_offset,
513                    committable_offset = self.sm.committable_offset,
514                    "received get tip response: {:?}",
515                    get_tip_response.shard_to_max_offset_map
516                );
517                let GetChainTipResponse {
518                    shard_to_max_offset_map,
519                    ..
520                } = get_tip_response;
521                if shard_to_max_offset_map.is_empty() {
522                    tracing::warn!("get tip response is empty, no shard to max offset map");
523                    return;
524                }
525                if let Some(tip) = shard_to_max_offset_map.values().max() {
526                    tracing::trace!("tip is {tip}");
527                    #[cfg(feature = "prometheus")]
528                    {
529                        use crate::metrics::set_fumarole_blockchain_offset_tip;
530                        set_fumarole_blockchain_offset_tip(*tip);
531                    }
532                }
533            }
534        }
535    }
536
537    fn drain_commit_offset_queue(&mut self) {
538        while let Some(mut commit_event) = self.shared_commit_offset_queue.pop() {
539            let Some(commit_seq) = commit_event.sequence.take() else {
540                continue;
541            };
542            self.sm.mark_event_as_processed(commit_seq);
543        }
544    }
545
546    pub(crate) async fn run(mut self) {
547        self.poll_history_if_needed().await;
548
549        // Always start to commit offset, to make sure not another instance is committing to the same offset.
550        unsafe {
551            self.force_commit_offset().await;
552        }
553        let mut ticks = 0;
554        while !self.stop {
555            ticks += 1;
556            if ticks % self.gc_interval == 0 {
557                self.sm.gc();
558                ticks = 0;
559            }
560            if self.outlet.is_closed() {
561                tracing::debug!("Detected dragonsmouth outlet closed");
562                break;
563            }
564
565            #[cfg(feature = "prometheus")]
566            {
567                let slot_status_update_queue_len = self.sm.slot_status_update_queue_len();
568                set_slot_status_update_queue_len(slot_status_update_queue_len);
569            }
570
571            let get_tip_deadline = self.last_tip + self.get_tip_interval;
572            let commit_deadline = self.last_commit + self.commit_interval;
573
574            self.drain_commit_offset_queue();
575            self.poll_history_if_needed().await;
576            self.schedule_download_task_if_any().await;
577            tokio::select! {
578                Some(subscribe_request) = self.dragonsmouth_bidi.rx.recv() => {
579                    tracing::debug!("dragonsmouth subscribe request received");
580                    // self.subscribe_request = subscribe_request
581                    self.handle_new_subscribe_request(subscribe_request).await;
582                }
583                control_response = self.control_plane_rx.next() => {
584                    match control_response {
585                        Some(result) => {
586                            match self.handle_control_plane_resp(result).await {
587                                LoopInstruction::Continue => {
588                                    // continue
589                                }
590                                LoopInstruction::ErrorStop => {
591                                    tracing::debug!("control plane error");
592                                    break;
593                                }
594                            }
595                        }
596                        None => {
597                            tracing::debug!("control plane disconnected");
598                            break;
599                        }
600                    }
601                }
602                Some(result) = self.non_critical_background_jobs.join_next() => {
603                    match result {
604                        Ok(result) => {
605                            self.handle_non_critical_job_result(result);
606                        }
607                        Err(e) => {
608                            tracing::warn!("non critical background job error with: {e:?}");
609                        }
610                    }
611                }
612                maybe = self.download_task_runner_chans.download_result_rx.recv() => {
613                    match maybe {
614                        Some(result) => {
615                            self.handle_download_result(result).await;
616                        },
617                        None => {
618                            tracing::info!("download task runner channel closed");
619                            break;
620                        }
621                    }
622                }
623                result = &mut self.download_task_runner_jh => {
624                    match result {
625                        Ok(Ok(())) => {
626                            tracing::error!("download task runner exited");
627                        }
628                        Ok(Err(DownloadBlockError::FailedDownload(dataplane_err))) => {
629                            let _ = self
630                                .outlet
631                                .send(Err(dataplane_err.into()))
632                                .await;
633                        }
634                        Ok(Err(DownloadBlockError::OutletDisconnected)) => {
635                            tracing::debug!("download task runner exited due to outlet disconnection");
636                        }
637                        Err(join_err) => {
638                            let err = DataplaneStreamError::new(
639                                DataplaneErrorKind::NonRecoverable,
640                                "download task runner join error".to_string(),
641                                Some(Box::new(join_err)),
642                            );
643                            let _ = self
644                                .outlet
645                                .send(Err(err.into()))
646                                .await;
647                        }
648                    }
649                    break;
650                }
651
652                _ = tokio::time::sleep_until(commit_deadline.into()) => {
653                    tracing::debug!(
654                        download_queue_capacity = self.download_task_runner_chans.download_task_queue_tx.capacity(),
655                        "commit deadline reached"
656                    );
657                    self.commit_offset().await;
658                }
659                _ = tokio::time::sleep_until(get_tip_deadline.into()) => {
660                    self.update_tip().await;
661                }
662            }
663            self.drain_slot_status().await;
664        }
665        self.stop = true;
666        tracing::debug!("fumarole runtime exiting");
667    }
668}
669
670///
671/// Channels to interact with a "download task runner".
672///
673/// Instead of using Trait which does not work very well for Asynchronous Specs,
674/// we use channels to create polymorphic behaviour (indirection-through-channels),
675/// similar to how actor-based programming works.
676///
677pub struct DownloadTaskRunnerChannels {
678    ///
679    /// Where you send download task request to.
680    ///
681    pub download_task_queue_tx: mpsc::Sender<DownloadTaskArgs>,
682
683    ///
684    /// Sends command the download task runner.
685    ///
686    pub cnc_tx: mpsc::Sender<DownloadTaskRunnerCommand>,
687
688    ///
689    /// Where you get back feedback from download task result.
690    pub download_result_rx: mpsc::Receiver<CompletedDownloadBlockTask>,
691}
692
693pub enum DownloadTaskRunnerCommand {
694    UpdateSubscribeRequest(Arc<SubscribeRequest>),
695}
696
697///
698/// The download task specification to use by the runner.
699///
700#[derive(Debug, Clone)]
701pub struct DownloadTaskArgs {
702    pub download_request: FumeDownloadRequest,
703}
704
705#[derive(Clone, Debug)]
706struct ScheduleShardDownload {
707    blockchain_id: Vec<u8>,
708    shard_idx: FumeShardIdx,
709    block_uid: Vec<u8>,
710    attempt: usize,
711    slot: Slot,
712}
713
714#[derive(Debug, Clone, PartialEq, Eq, Hash)]
715enum DedupKey {
716    Account {
717        pubkey: Vec<u8>,
718        txn_signature: Option<Vec<u8>>,
719    },
720    Transaction {
721        index: u64,
722    },
723    Entry {
724        index: u64,
725    },
726}
727
728const DEDUP_WINDOW_SIZE: usize = 100_000;
729const ORCHESTRATOR_DOWNLOADER_QUEUE_CAPACITY: usize = 2;
730const PENDING_SHARD_DOWNLOAD_RETRY_INTERVAL: Duration = Duration::from_millis(50);
731
732#[derive(Default, Debug, Clone)]
733struct DedupState {
734    seen: HashMap<(u64, FumeShardIdx), FxHashSet<DedupKey>>,
735    completed_shards: FxHashSet<(u64, FumeShardIdx)>,
736    completed_order: VecDeque<(u64, FumeShardIdx)>,
737}
738
739impl DedupState {
740    /// Returns true when the event should be skipped as duplicate.
741    fn dedup(&mut self, slot: u64, shard_idx: FumeShardIdx, ev: &UpdateOneof) -> bool {
742        if self.is_shard_done(slot, shard_idx) {
743            return true;
744        }
745
746        let Some(key) = Self::key_for_update(ev) else {
747            // Non-deduped event kinds are skipped by default.
748            return true;
749        };
750        let shard_key = (slot, shard_idx);
751        let shard_seen = self.seen.entry(shard_key).or_default();
752        !shard_seen.insert(key)
753    }
754
755    fn mark_shard_done(&mut self, slot: u64, shard_idx: FumeShardIdx) {
756        let shard_key = (slot, shard_idx);
757        if !self.completed_shards.insert(shard_key) {
758            return;
759        }
760        self.completed_order.push_back(shard_key);
761
762        self.seen.remove(&shard_key);
763
764        if self.completed_order.len() > DEDUP_WINDOW_SIZE {
765            if let Some(oldest) = self.completed_order.pop_front() {
766                self.completed_shards.remove(&oldest);
767            }
768        }
769    }
770
771    fn is_shard_done(&self, slot: u64, shard_idx: FumeShardIdx) -> bool {
772        self.completed_shards.contains(&(slot, shard_idx))
773    }
774
775    fn shrink_seen_if_needed(&mut self) {
776        // `seen` is keyed by shard (slot, shard_idx) and is aggressively removed
777        // in `mark_shard_done`, so no additional key-level compaction is required.
778        while self.completed_order.len() > DEDUP_WINDOW_SIZE {
779            if let Some(oldest) = self.completed_order.pop_front() {
780                self.completed_shards.remove(&oldest);
781            }
782        }
783    }
784
785    fn key_for_update(ev: &UpdateOneof) -> Option<DedupKey> {
786        match ev {
787            UpdateOneof::Account(msg) => {
788                msg.account.as_ref().map(|account_info| DedupKey::Account {
789                    pubkey: account_info.pubkey.clone(),
790                    txn_signature: account_info.txn_signature.clone(),
791                })
792            }
793            UpdateOneof::Slot(_) => None,
794            UpdateOneof::Transaction(msg) => {
795                msg.transaction
796                    .as_ref()
797                    .map(|tx_info| DedupKey::Transaction {
798                        index: tx_info.index,
799                    })
800            }
801            UpdateOneof::TransactionStatus(msg) => Some(DedupKey::Transaction { index: msg.index }),
802            UpdateOneof::BlockMeta(_)
803            | UpdateOneof::Block(_)
804            | UpdateOneof::Ping(_)
805            | UpdateOneof::Pong(_) => None,
806            UpdateOneof::Entry(msg) => Some(DedupKey::Entry { index: msg.index }),
807        }
808    }
809}
810
811pub(crate) struct PipelinedShardDownloader<Outlet> {
812    cnc_rx: mpsc::Receiver<Arc<SubscribeRequest>>,
813    download_completed_tx: mpsc::Sender<CompletedDownloadBlockShardTask>,
814    subscribe_request: Arc<SubscribeRequest>,
815    dragonsmouth_outlet: Outlet,
816    shard_scheduled_for_download: VecDeque<ScheduleShardDownload>,
817    shard_download_queue: mpsc::Receiver<QueuedShardDownload>,
818}
819
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum DataplaneErrorKind {
822    RecoverableTransport,
823    SlotNotFound,
824    InvalidSubscribeFilter,
825    NonRecoverable,
826}
827
828#[derive(Debug, thiserror::Error)]
829#[error("dataplane stream error ({kind:?}): {message}")]
830pub struct DataplaneStreamError {
831    kind: DataplaneErrorKind,
832    message: String,
833    #[source]
834    source: Option<Box<dyn std::error::Error + Send + Sync>>,
835}
836
837impl DataplaneStreamError {
838    pub(crate) fn new(
839        kind: DataplaneErrorKind,
840        message: String,
841        source: Option<Box<dyn std::error::Error + Send + Sync>>,
842    ) -> Self {
843        Self {
844            kind,
845            message,
846            source,
847        }
848    }
849
850    pub const fn kind(&self) -> DataplaneErrorKind {
851        self.kind
852    }
853
854    pub const fn is_recoverable(&self) -> bool {
855        matches!(self.kind, DataplaneErrorKind::RecoverableTransport)
856    }
857}
858
859#[derive(Debug, thiserror::Error)]
860pub(crate) enum DownloadBlockError {
861    #[error("dragonsmouth outlet disconnected")]
862    OutletDisconnected,
863    #[error(transparent)]
864    FailedDownload(#[from] DataplaneStreamError),
865}
866
867pub struct CompletedDownloadBlockTask {
868    slot: u64,
869    #[allow(dead_code)]
870    block_uid: [u8; 16],
871    shard_idx_vec: Vec<FumeShardIdx>,
872}
873
874#[derive(Debug)]
875pub struct CompletedDownloadBlockShardTask {
876    shard_idx: FumeShardIdx,
877    block_uid: [u8; 16],
878    block_meta: Option<SubscribeUpdate>,
879    slot: Slot,
880}
881
882#[derive(Debug, thiserror::Error)]
883enum ShardDownloaderErrorKind {
884    #[error(transparent)]
885    DataplaneError(#[from] DataplaneStreamError),
886    #[error("outlet disconnected")]
887    SinkClosed,
888    #[error("subscribe data sending-half disconnected")]
889    SubscribeDataTxDisconnected,
890}
891
892#[derive(Debug, thiserror::Error)]
893#[error("shard downloader error: {kind}")]
894struct ShardDownloaderError {
895    slot_scheduled_for_download: VecDeque<ScheduleShardDownload>,
896    shard_download_queue_rx: mpsc::Receiver<QueuedShardDownload>,
897    recyclable_dedup_state: Option<DedupState>,
898    kind: ShardDownloaderErrorKind,
899}
900
901pub struct ShardDownloaderRecycleState {
902    scheduled_shard_downloads: VecDeque<ScheduleShardDownload>,
903    shard_download_queue_rx: mpsc::Receiver<QueuedShardDownload>,
904    recyclable_dedup_state: DedupState,
905}
906
907type PipelinedDownloaderOutcome = Result<
908    (Option<CompletedDownloadBlockShardTask>, DedupState),
909    (ShardDownloaderErrorKind, DedupState),
910>;
911
912impl<Outlet> PipelinedShardDownloader<Outlet>
913where
914    Outlet: Sink<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>> + Unpin + Send + 'static,
915{
916    async fn pipelined_downloader<Source>(
917        mut rx: Source,
918        mut outlet: Outlet,
919        completed_slot_tx: mpsc::Sender<CompletedDownloadBlockShardTask>,
920        mut dedup_state: DedupState,
921        mut scheduled_shard_rx: mpsc::UnboundedReceiver<(u64, FumeShardIdx)>,
922    ) -> PipelinedDownloaderOutcome
923    where
924        Source: Stream<Item = Result<DataResponse, DataplaneStreamError>> + Unpin,
925    {
926        let mut total_event_downloaded = 0;
927        let mut block_meta: Option<SubscribeUpdate> = None;
928        let mut scheduled_shards = VecDeque::new();
929        let mut t = Instant::now();
930        tracing::trace!("starting continuous shard downloader loop");
931        while let Some(data) = rx.next().await {
932            while let Ok(shard_ctx) = scheduled_shard_rx.try_recv() {
933                scheduled_shards.push_back(shard_ctx);
934            }
935
936            let resp = match data {
937                Ok(data) => data.response.expect("response"),
938                Err(err) => {
939                    return Err((ShardDownloaderErrorKind::DataplaneError(err), dedup_state));
940                }
941            };
942
943            match resp {
944                data_response::Response::Update(update) => {
945                    if update.update_oneof.as_ref().is_none() {
946                        continue;
947                    }
948
949                    total_event_downloaded += 1;
950                    #[cfg(feature = "prometheus")]
951                    {
952                        inc_total_event_downloaded(1);
953                    }
954                    #[allow(clippy::collapsible_else_if)]
955                    if matches!(update.update_oneof, Some(UpdateOneof::BlockMeta(_))) {
956                        block_meta = Some(update);
957                    } else {
958                        let event_slot = if let Some((expected_slot, expected_shard_idx)) =
959                            scheduled_shards
960                                .front()
961                                .map(|(slot, shard_idx)| (*slot, *shard_idx))
962                        {
963                            if dedup_state.dedup(
964                                expected_slot,
965                                expected_shard_idx,
966                                update
967                                    .update_oneof
968                                    .as_ref()
969                                    .expect("update oneof must be set"),
970                            ) {
971                                continue;
972                            }
973                            expected_slot
974                        } else {
975                            // In tests and during startup races we might receive updates before
976                            // scheduled shard context is visible here. Skip dedup in that case.
977                            match update.update_oneof.as_ref() {
978                                Some(UpdateOneof::Account(msg)) => msg.slot,
979                                Some(UpdateOneof::Slot(msg)) => msg.slot,
980                                Some(UpdateOneof::Transaction(msg)) => msg.slot,
981                                Some(UpdateOneof::TransactionStatus(msg)) => msg.slot,
982                                Some(UpdateOneof::Block(msg)) => msg.slot,
983                                Some(UpdateOneof::BlockMeta(msg)) => msg.slot,
984                                Some(UpdateOneof::Entry(msg)) => msg.slot,
985                                Some(UpdateOneof::Ping(_)) | Some(UpdateOneof::Pong(_)) | None => {
986                                    continue;
987                                }
988                            }
989                        };
990
991                        if futures::future::poll_fn(|cx| outlet.poll_ready_unpin(cx))
992                            .await
993                            .is_err()
994                        {
995                            return Err((ShardDownloaderErrorKind::SinkClosed, dedup_state));
996                        }
997                        if outlet
998                            .start_send_unpin(Ok(FumaroleRuntimeEvent::Data(
999                                FumaroleRuntimeDataEvent {
1000                                    slot: event_slot,
1001                                    update,
1002                                },
1003                            )))
1004                            .is_err()
1005                        {
1006                            return Err((ShardDownloaderErrorKind::SinkClosed, dedup_state));
1007                        }
1008                    }
1009                }
1010                data_response::Response::BlockShardDownloadFinish(footer) => {
1011                    dedup_state.mark_shard_done(footer.slot, footer.shard_indices[0]);
1012                    dedup_state.shrink_seen_if_needed();
1013                    tracing::trace!(
1014                        "shard {} for slot {} download finished, dedup state updated",
1015                        footer.shard_indices[0],
1016                        footer.slot
1017                    );
1018                    if let Some((scheduled_slot, scheduled_shard_idx)) =
1019                        scheduled_shards.pop_front()
1020                    {
1021                        debug_assert_eq!(
1022                            scheduled_slot, footer.slot,
1023                            "slot mismatch for scheduled shard completion"
1024                        );
1025                        debug_assert_eq!(
1026                            scheduled_shard_idx, footer.shard_indices[0],
1027                            "shard idx mismatch for scheduled shard completion"
1028                        );
1029                    }
1030
1031                    let download_time = t.elapsed();
1032                    #[cfg(feature = "prometheus")]
1033                    {
1034                        use crate::metrics::observe_download_shard_download_time;
1035
1036                        observe_download_shard_download_time(download_time);
1037                    }
1038                    assert!(footer.shard_indices.len() == 1);
1039                    tracing::trace!(
1040                        "shard {} download finished with {} events in {:?}",
1041                        footer.shard_indices[0],
1042                        total_event_downloaded,
1043                        download_time,
1044                    );
1045                    total_event_downloaded = 0;
1046                    let completed = CompletedDownloadBlockShardTask {
1047                        shard_idx: footer.shard_indices[0],
1048                        block_meta: block_meta.clone(),
1049                        block_uid: footer
1050                            .block_uid
1051                            .try_into()
1052                            .expect("block uid size mismatch"),
1053                        slot: footer.slot,
1054                    };
1055                    if let Err(e) = completed_slot_tx.send(completed).await {
1056                        let completed = e.0;
1057                        return Ok((Some(completed), dedup_state));
1058                    }
1059                    t = Instant::now();
1060                }
1061            }
1062        }
1063        tracing::trace!("exiting continuous shard downloader loop");
1064        Ok((None, dedup_state))
1065    }
1066
1067    async fn downloader_loop<DataplaneSink, DataplaneStream>(
1068        mut self,
1069        mut dataplane_sink: DataplaneSink,
1070        dataplane_stream: DataplaneStream,
1071        dedup_state: DedupState,
1072    ) -> Result<ShardDownloaderRecycleState, ShardDownloaderError>
1073    where
1074        DataplaneSink: Sink<DataCommand> + Send + Unpin + 'static,
1075        DataplaneStream:
1076            Stream<Item = Result<DataResponse, DataplaneStreamError>> + Send + Unpin + 'static,
1077    {
1078        let initial_filter_update_result = dataplane_sink
1079            .send(DataCommand {
1080                command: Some(Command::FilterUpdate(
1081                    (*self.subscribe_request).clone().into(),
1082                )),
1083            })
1084            .await;
1085        if initial_filter_update_result.is_err() {
1086            return Err(ShardDownloaderError {
1087                kind: ShardDownloaderErrorKind::SubscribeDataTxDisconnected,
1088                slot_scheduled_for_download: self.shard_scheduled_for_download,
1089                shard_download_queue_rx: self.shard_download_queue,
1090                recyclable_dedup_state: Some(dedup_state),
1091            });
1092        }
1093        let mut joinset = JoinSet::new();
1094        let (scheduled_shard_tx, scheduled_shard_rx) = mpsc::unbounded_channel();
1095
1096        let dragonsmouth_outlet = self.dragonsmouth_outlet;
1097        let (completed_slot_tx, mut completed_slot_rx) = mpsc::channel(10);
1098        joinset.spawn(Self::pipelined_downloader(
1099            dataplane_stream,
1100            dragonsmouth_outlet,
1101            completed_slot_tx,
1102            dedup_state,
1103            scheduled_shard_rx,
1104        ));
1105
1106        let mut recyclable_dedup_state = None;
1107        let err = loop {
1108            let poll_shared_queue = self.shard_scheduled_for_download.len() < 2;
1109            tokio::select! {
1110                maybe = self.cnc_rx.recv() => {
1111                    match maybe {
1112                        Some(subscribe_request) => {
1113                            self.subscribe_request = subscribe_request;
1114                            tracing::info!("updated subscribe request in continuous shard downloader");
1115                            let cmd = crate::proto::data_command::Command::FilterUpdate((*self.subscribe_request).clone().into());
1116                            let result = dataplane_sink.send(DataCommand {
1117                                command: Some(cmd)
1118                            }).await;
1119                            if result.is_err() {
1120                                break Some(ShardDownloaderErrorKind::SubscribeDataTxDisconnected);
1121                            }
1122                        },
1123                        None => {
1124                            break None;
1125                        }
1126                    }
1127                }
1128                maybe = completed_slot_rx.recv() => {
1129                    match maybe {
1130                        Some(completed) => {
1131                            let expected_shard_download = self.shard_scheduled_for_download.pop_front().expect("no scheduled shard download");
1132                            assert!(expected_shard_download.shard_idx == completed.shard_idx, "shard idx mismatch");
1133                            assert!(expected_shard_download.block_uid == completed.block_uid, "block uid mismatch");
1134                            // Notify the caller about completed shard download
1135                            if self.download_completed_tx.send(completed).await.is_err() {
1136                                break None;
1137                            }
1138                        },
1139                        None => {
1140                            break None;
1141                        }
1142                    }
1143                }
1144                Some(result) = joinset.join_next() => {
1145                    let result2 = result.expect("join error");
1146                    match result2 {
1147                        Ok((maybe_completed, dedup_state)) => {
1148                            recyclable_dedup_state = Some(dedup_state);
1149                            if let Some(completed) = maybe_completed {
1150                                let expected_shard_download = self
1151                                    .shard_scheduled_for_download
1152                                    .pop_front()
1153                                    .expect("no scheduled shard download");
1154                                assert!(
1155                                    expected_shard_download.shard_idx == completed.shard_idx,
1156                                    "shard idx mismatch"
1157                                );
1158                                assert!(
1159                                    expected_shard_download.block_uid == completed.block_uid,
1160                                    "block uid mismatch"
1161                                );
1162                                let _ = self.download_completed_tx.send(completed).await;
1163                            }
1164                            break None
1165                        }
1166                        Err((e, dedup_state)) => {
1167                            recyclable_dedup_state = Some(dedup_state);
1168                            break Some(e)
1169                        },
1170                    }
1171
1172                }
1173                result = self.shard_download_queue.recv(), if poll_shared_queue => {
1174                    let Some(queued_shard_download) = result else {
1175                        break None;
1176                    };
1177                    let scheduled_shard_download = ScheduleShardDownload {
1178                        blockchain_id: queued_shard_download.request.blockchain_id.clone(),
1179                        shard_idx: queued_shard_download.request.shard_idx as u32,
1180                        block_uid: queued_shard_download.request.block_uid.clone(),
1181                        slot: queued_shard_download.slot,
1182                        attempt: queued_shard_download.attempt,
1183                    };
1184                    self.shard_scheduled_for_download.push_back(scheduled_shard_download);
1185                    let _ = scheduled_shard_tx.send((queued_shard_download.slot, queued_shard_download.request.shard_idx as u32));
1186                    let cmd = crate::proto::data_command::Command::DownloadBlockShard(queued_shard_download.request);
1187                    let result = dataplane_sink.send(DataCommand {
1188                        command: Some(cmd)
1189                    }).await;
1190
1191
1192                    if result.is_err() {
1193                        break Some(ShardDownloaderErrorKind::SubscribeDataTxDisconnected);
1194                    }
1195
1196                }
1197            }
1198        };
1199        drop(completed_slot_rx);
1200
1201        if recyclable_dedup_state.is_none() {
1202            if let Some(result) = joinset.join_next().await {
1203                let result2 = result.expect("join error");
1204                match result2 {
1205                    Ok((maybe_completed, dedup_state)) => {
1206                        recyclable_dedup_state = Some(dedup_state);
1207                        if let Some(completed) = maybe_completed {
1208                            let expected_shard_download = self
1209                                .shard_scheduled_for_download
1210                                .pop_front()
1211                                .expect("no scheduled shard download");
1212                            assert!(
1213                                expected_shard_download.shard_idx == completed.shard_idx,
1214                                "shard idx mismatch"
1215                            );
1216                            assert!(
1217                                expected_shard_download.block_uid == completed.block_uid,
1218                                "block uid mismatch"
1219                            );
1220                            let _ = self.download_completed_tx.send(completed).await;
1221                        }
1222                    }
1223                    Err((err_kind, dedup_state)) => {
1224                        recyclable_dedup_state = Some(dedup_state);
1225                        if err.is_none() {
1226                            return Err(ShardDownloaderError {
1227                                kind: err_kind,
1228                                slot_scheduled_for_download: self.shard_scheduled_for_download,
1229                                shard_download_queue_rx: self.shard_download_queue,
1230                                recyclable_dedup_state,
1231                            });
1232                        }
1233                    }
1234                }
1235            }
1236        }
1237
1238        let recyclable_dedup_state = recyclable_dedup_state.unwrap_or_default();
1239
1240        if let Some(err_kind) = err {
1241            Err(ShardDownloaderError {
1242                kind: err_kind,
1243                slot_scheduled_for_download: self.shard_scheduled_for_download,
1244                shard_download_queue_rx: self.shard_download_queue,
1245                recyclable_dedup_state: Some(recyclable_dedup_state),
1246            })
1247        } else {
1248            Ok(ShardDownloaderRecycleState {
1249                scheduled_shard_downloads: self.shard_scheduled_for_download,
1250                shard_download_queue_rx: self.shard_download_queue,
1251                recyclable_dedup_state,
1252            })
1253        }
1254    }
1255}
1256
1257struct ShardedSlotDownloadProgress {
1258    started_at: Instant,
1259    block_meta: Option<SubscribeUpdate>,
1260    remaining_shard_idx: Vec<FumeShardIdx>,
1261}
1262
1263struct QueuedShardDownload {
1264    slot: Slot,
1265    request: DownloadBlockShard,
1266    attempt: usize,
1267}
1268
1269struct PendingShardDownload {
1270    downloader_idx: usize,
1271    queued: QueuedShardDownload,
1272}
1273
1274/// Orchestrates shard downloads across `N` pinned downloader workers.
1275///
1276/// Affinity model:
1277/// 1. Each downloader index `i` owns exactly one queue `queue[i]`.
1278/// 2. A shard `j` is always routed to `queue[j % N]`.
1279/// 3. If downloader `i` fails, recovered work is recycled back to `queue[i]`
1280///    before respawn, so no shard is redirected to another downloader index.
1281///
1282/// This design keeps ordering and ownership stable per shard lane while still
1283/// allowing independent downloader restarts.
1284///
1285/// Additional benefits:
1286/// 1. Downloaders can run parallel, non-shared dedup logic at lane level,
1287///    because each lane owns a stable subset of shards.
1288/// 2. The backend can perform better shard download prediction thanks to
1289///    deterministic shard-to-downloader affinity over time.
1290pub(crate) struct ShardedDownloadOrchestrator<C> {
1291    shard_download_queue_txs: Vec<mpsc::Sender<QueuedShardDownload>>,
1292    shard_download_queue_rxs: Vec<Option<mpsc::Receiver<QueuedShardDownload>>>,
1293    slot_download_progression_map: HashMap<Slot, ShardedSlotDownloadProgress>,
1294    completed_tx: mpsc::Sender<CompletedDownloadBlockShardTask>,
1295    completed_rx: mpsc::Receiver<CompletedDownloadBlockShardTask>,
1296    connector: C,
1297    shard_downloader_js: JoinSet<Result<ShardDownloaderRecycleState, ShardDownloaderError>>,
1298    shard_downloader_handles: HashMap<task::Id, PipelinedShardDownloaderHandle>,
1299    cnc_rx: mpsc::Receiver<DownloadTaskRunnerCommand>,
1300    download_task_queue: mpsc::Receiver<DownloadTaskArgs>,
1301    outlet: mpsc::Sender<CompletedDownloadBlockTask>,
1302    max_download_attempt_per_slot: usize,
1303    subscribe_request: Arc<SubscribeRequest>,
1304    dragonsmouth_outlet: mpsc::Sender<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
1305    total_shard_downloaders: usize,
1306    pending_shard_downloads: VecDeque<PendingShardDownload>,
1307}
1308
1309///
1310/// Holds information about on-going data plane task.
1311///
1312#[derive(Debug, Clone)]
1313pub(crate) struct PipelinedShardDownloaderHandle {
1314    downloader_idx: usize,
1315    cnc_tx: mpsc::Sender<Arc<SubscribeRequest>>,
1316}
1317
1318impl<C> ShardedDownloadOrchestrator<C>
1319where
1320    C: FumaroleDataplaneConnector + Send + 'static,
1321    DataplaneStreamError: From<C::DataplaneSubscribeError>,
1322{
1323    #[allow(clippy::too_many_arguments)]
1324    pub fn new(
1325        connector: C,
1326        cnc_rx: mpsc::Receiver<DownloadTaskRunnerCommand>,
1327        download_task_queue: mpsc::Receiver<DownloadTaskArgs>,
1328        outlet: mpsc::Sender<CompletedDownloadBlockTask>,
1329        max_download_attempt_by_slot: usize,
1330        subscribe_request: Arc<SubscribeRequest>,
1331        total_shard_downloaders: usize,
1332        dragonsmouth_outlet: mpsc::Sender<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
1333    ) -> Self {
1334        let (completed_tx, completed_rx) = mpsc::channel(1000);
1335        let mut shard_download_queue_txs = Vec::with_capacity(total_shard_downloaders);
1336        let mut shard_download_queue_rxs = Vec::with_capacity(total_shard_downloaders);
1337        for _ in 0..total_shard_downloaders {
1338            let (tx, rx) = mpsc::channel(ORCHESTRATOR_DOWNLOADER_QUEUE_CAPACITY);
1339            shard_download_queue_txs.push(tx);
1340            shard_download_queue_rxs.push(Some(rx));
1341        }
1342        Self {
1343            connector,
1344            shard_downloader_js: JoinSet::new(),
1345            shard_downloader_handles: HashMap::new(),
1346            cnc_rx,
1347            download_task_queue,
1348            outlet,
1349            max_download_attempt_per_slot: max_download_attempt_by_slot,
1350            subscribe_request,
1351            shard_download_queue_txs,
1352            shard_download_queue_rxs,
1353            slot_download_progression_map: HashMap::new(),
1354            dragonsmouth_outlet,
1355            total_shard_downloaders,
1356            completed_tx,
1357            completed_rx,
1358            pending_shard_downloads: VecDeque::new(),
1359        }
1360    }
1361
1362    const fn queue_idx_for_shard(&self, shard_idx: FumeShardIdx) -> usize {
1363        shard_idx as usize % self.total_shard_downloaders
1364    }
1365
1366    /// Replaces downloader `i` queue endpoints with a fresh channel pair.
1367    ///
1368    /// This must happen before recycling, so recovered work is enqueued into
1369    /// the new queue instance that the next downloader task will consume.
1370    fn reset_downloader_queue(&mut self, downloader_idx: usize) {
1371        let (tx, rx) = mpsc::channel(ORCHESTRATOR_DOWNLOADER_QUEUE_CAPACITY);
1372        self.shard_download_queue_txs[downloader_idx] = tx;
1373        self.shard_download_queue_rxs[downloader_idx] = Some(rx);
1374    }
1375
1376    /// Routes a queued download using shard affinity (`shard_idx % N`).
1377    ///
1378    /// Normal scheduling and retry paths should call this helper when they
1379    /// want default affinity routing.
1380    fn route_queued_download(&mut self, queued: QueuedShardDownload) {
1381        let shard_idx = queued.request.shard_idx as u32;
1382        let downloader_idx = self.queue_idx_for_shard(shard_idx);
1383        self.route_queued_download_to_downloader(downloader_idx, queued);
1384    }
1385
1386    /// Routes a queued download to an explicit downloader queue index.
1387    ///
1388    /// Recovery paths use this to preserve lane ownership when recycling work
1389    /// from a failed downloader.
1390    fn route_queued_download_to_downloader(
1391        &mut self,
1392        downloader_idx: usize,
1393        queued: QueuedShardDownload,
1394    ) {
1395        match self.shard_download_queue_txs[downloader_idx].try_send(queued) {
1396            Ok(()) => {}
1397            Err(TrySendError::Full(queued)) => {
1398                self.pending_shard_downloads
1399                    .push_back(PendingShardDownload {
1400                        downloader_idx,
1401                        queued,
1402                    });
1403            }
1404            Err(TrySendError::Closed(_)) => {
1405                panic!("shard downloader queue unexpectedly closed");
1406            }
1407        }
1408    }
1409
1410    fn drain_pending_shard_downloads(&mut self) {
1411        while let Some(pending) = self.pending_shard_downloads.pop_front() {
1412            match self.shard_download_queue_txs[pending.downloader_idx].try_send(pending.queued) {
1413                Ok(()) => {}
1414                Err(TrySendError::Full(queued)) => {
1415                    self.pending_shard_downloads
1416                        .push_front(PendingShardDownload {
1417                            downloader_idx: pending.downloader_idx,
1418                            queued,
1419                        });
1420                    break;
1421                }
1422                Err(TrySendError::Closed(_)) => {
1423                    panic!("shard downloader queue unexpectedly closed");
1424                }
1425            }
1426        }
1427    }
1428
1429    /// Re-enqueues scheduled (already-issued) shard downloads back to the
1430    /// pinned queue of downloader `i`.
1431    ///
1432    /// Affinity invariant: for every recycled item, `shard_idx % N == i`.
1433    async fn recycle_scheduled_shard_downloads(
1434        &mut self,
1435        downloader_idx: usize,
1436        shard_downloads: VecDeque<ScheduleShardDownload>,
1437    ) {
1438        for scheduled_shard_download in shard_downloads {
1439            let expected_downloader_idx =
1440                self.queue_idx_for_shard(scheduled_shard_download.shard_idx);
1441            debug_assert_eq!(
1442                expected_downloader_idx, downloader_idx,
1443                "scheduled shard affinity mismatch during recycle"
1444            );
1445            let queued = QueuedShardDownload {
1446                slot: scheduled_shard_download.slot,
1447                request: DownloadBlockShard {
1448                    blockchain_id: scheduled_shard_download.blockchain_id,
1449                    block_uid: scheduled_shard_download.block_uid,
1450                    shard_idx: scheduled_shard_download.shard_idx as i32,
1451                    block_filters: None,
1452                    slot: Some(scheduled_shard_download.slot),
1453                },
1454                attempt: scheduled_shard_download.attempt,
1455            };
1456            self.route_queued_download_to_downloader(downloader_idx, queued);
1457        }
1458    }
1459
1460    /// Drains all currently buffered queued downloads from the old receiver and
1461    /// re-enqueues them into downloader `i` new queue.
1462    ///
1463    /// `try_recv` is used intentionally to perform a non-blocking snapshot drain:
1464    /// read what is already buffered, then stop when empty.
1465    async fn recycle_queued_shard_downloads(
1466        &mut self,
1467        downloader_idx: usize,
1468        mut shard_download_queue_rx: mpsc::Receiver<QueuedShardDownload>,
1469    ) {
1470        while let Ok(queued_download) = shard_download_queue_rx.try_recv() {
1471            let expected_downloader_idx =
1472                self.queue_idx_for_shard(queued_download.request.shard_idx as u32);
1473            debug_assert_eq!(
1474                expected_downloader_idx, downloader_idx,
1475                "queued shard affinity mismatch during recycle"
1476            );
1477            self.route_queued_download_to_downloader(downloader_idx, queued_download);
1478        }
1479    }
1480
1481    async fn spawn_shard_downloader(
1482        &mut self,
1483        downloader_idx: usize,
1484        recycled_dedup_state: Option<DedupState>,
1485    ) -> Result<(), DownloadBlockError> {
1486        let (dataplane_tx, dataplane_stream) = self
1487            .connector
1488            .subscribe_data()
1489            .await
1490            .map_err(|e| DownloadBlockError::FailedDownload(DataplaneStreamError::from(e)))?;
1491
1492        let shard_download_queue = self.shard_download_queue_rxs[downloader_idx]
1493            .take()
1494            .expect("missing shard download queue receiver");
1495
1496        let (cnc_tx, cnc_rx) = mpsc::channel(10);
1497        let dragonsmouth_sink = PollSender::new(self.dragonsmouth_outlet.clone());
1498        let shard_downloader = PipelinedShardDownloader {
1499            cnc_rx,
1500            download_completed_tx: self.completed_tx.clone(),
1501            subscribe_request: Arc::clone(&self.subscribe_request),
1502            dragonsmouth_outlet: dragonsmouth_sink,
1503            shard_scheduled_for_download: Default::default(),
1504            shard_download_queue,
1505        };
1506        let ah = self.shard_downloader_js.spawn(async move {
1507            shard_downloader
1508                .downloader_loop(
1509                    dataplane_tx,
1510                    dataplane_stream,
1511                    recycled_dedup_state.unwrap_or_default(),
1512                )
1513                .await
1514        });
1515        let handle = PipelinedShardDownloaderHandle {
1516            downloader_idx,
1517            cnc_tx,
1518        };
1519        self.shard_downloader_handles.insert(ah.id(), handle);
1520        Ok(())
1521    }
1522
1523    async fn handle_shard_download_completed(
1524        &mut self,
1525        mut completed: CompletedDownloadBlockShardTask,
1526    ) {
1527        #[cfg(feature = "prometheus")]
1528        {
1529            dec_inflight_slot_download();
1530        }
1531
1532        tracing::trace!(
1533            "slot:short {}:{} download completed",
1534            completed.slot,
1535            completed.shard_idx
1536        );
1537
1538        let slot = completed.slot;
1539        let is_slot_complete = {
1540            let slot_progression = self
1541                .slot_download_progression_map
1542                .get_mut(&slot)
1543                .expect("should track slot progression");
1544            slot_progression
1545                .remaining_shard_idx
1546                .retain(|x| x != &completed.shard_idx);
1547            if let Some(block_meta) = completed.block_meta.take() {
1548                slot_progression.block_meta = Some(block_meta);
1549            }
1550
1551            slot_progression.remaining_shard_idx.is_empty()
1552        };
1553
1554        // Handle any completed shards
1555        {
1556            // let elapsed = task_meta.scheduled_at.elapsed();
1557            #[cfg(feature = "prometheus")]
1558            {
1559                if is_slot_complete {
1560                    inc_slot_download_count();
1561                }
1562            }
1563            let _ = self
1564                .outlet
1565                .send(CompletedDownloadBlockTask {
1566                    slot,
1567                    block_uid: completed.block_uid,
1568                    shard_idx_vec: vec![completed.shard_idx],
1569                })
1570                .await;
1571            if is_slot_complete {
1572                let completed = self.slot_download_progression_map.remove(&slot).unwrap();
1573                let elapsed = completed.started_at.elapsed();
1574                tracing::trace!("slot {} download completed in {:?}", slot, elapsed);
1575                #[cfg(feature = "prometheus")]
1576                {
1577                    observe_slot_download_duration(elapsed);
1578                }
1579                let block_meta = completed.block_meta;
1580                tracing::trace!("slot {slot} download completed");
1581                if let Some(block_meta) = block_meta {
1582                    let _ = self
1583                        .dragonsmouth_outlet
1584                        .send(Ok(FumaroleRuntimeEvent::Data(FumaroleRuntimeDataEvent {
1585                            slot,
1586                            update: block_meta,
1587                        })))
1588                        .await;
1589                } else {
1590                    tracing::debug!(
1591                        slot,
1592                        "slot completed without block meta; emitting SlotEnded only"
1593                    );
1594                }
1595                let _ = self
1596                    .dragonsmouth_outlet
1597                    .send(Ok(FumaroleRuntimeEvent::SlotEnded(slot)))
1598                    .await;
1599
1600                #[cfg(feature = "prometheus")]
1601                {
1602                    inc_slot_download_count();
1603                }
1604            }
1605        }
1606    }
1607
1608    /// Splits one slot into per-shard downloads and enqueues each shard using
1609    /// the affinity rule `shard_idx % N`.
1610    async fn schedule_slot_download_task(&mut self, task_spec: DownloadTaskArgs) {
1611        if self
1612            .slot_download_progression_map
1613            .contains_key(&task_spec.download_request.slot)
1614        {
1615            // Already scheduled
1616            tracing::warn!(
1617                "slot {} already scheduled for download",
1618                task_spec.download_request.slot
1619            );
1620            return;
1621        }
1622        let slot = task_spec.download_request.slot;
1623        let num_shards = task_spec.download_request.num_shards;
1624        let shard_idx_vec = (0..num_shards).collect::<Vec<_>>();
1625        for shard_idx in &shard_idx_vec {
1626            let download_shard_task = DownloadBlockShard {
1627                blockchain_id: task_spec.download_request.blockchain_id.clone().to_vec(),
1628                block_uid: task_spec.download_request.block_uid.clone().to_vec(),
1629                shard_idx: *shard_idx as i32,
1630                block_filters: None,
1631                slot: Some(slot),
1632            };
1633            let queued_download = QueuedShardDownload {
1634                slot,
1635                request: download_shard_task,
1636                attempt: 1,
1637            };
1638            self.route_queued_download(queued_download);
1639        }
1640        let slot_progress = ShardedSlotDownloadProgress {
1641            started_at: Instant::now(),
1642            remaining_shard_idx: shard_idx_vec,
1643            block_meta: None,
1644        };
1645        self.slot_download_progression_map
1646            .insert(slot, slot_progress);
1647    }
1648
1649    /// Handles one downloader completion/failure and keeps shard-lane affinity.
1650    ///
1651    /// Recovery sequence:
1652    /// 1. Reset queue endpoints for failed downloader `i`.
1653    /// 2. Recycle all recoverable work back to queue `i`.
1654    /// 3. Spawn a new downloader bound to queue `i`.
1655    ///
1656    /// This prevents work from lane `i` being consumed by any other lane.
1657    async fn handle_shard_downloader_result(
1658        &mut self,
1659        task_id: Id,
1660        result: Result<ShardDownloaderRecycleState, ShardDownloaderError>,
1661    ) -> Result<(), DownloadBlockError> {
1662        let Some(handle) = self.shard_downloader_handles.remove(&task_id) else {
1663            return Ok(());
1664        };
1665        let downloader_idx = handle.downloader_idx;
1666
1667        // Recovery order for strict pinned architecture:
1668        // 1) Reset queue endpoints for downloader `i` (new tx/rx pair).
1669        // 2) Recycle scheduled + buffered queued work back into queue `i`.
1670        // 3) Spawn a fresh downloader `i` bound to that new receiver.
1671        //
1672        // This preserves shard affinity (`shard_idx % N == i`) across crashes and
1673        // avoids rerouting work to other downloader indices.
1674        self.reset_downloader_queue(downloader_idx);
1675
1676        let recycled_dedup_state = match result {
1677            Ok(shard_downloader_recycle_state) => {
1678                self.recycle_scheduled_shard_downloads(
1679                    downloader_idx,
1680                    shard_downloader_recycle_state.scheduled_shard_downloads,
1681                )
1682                .await;
1683                self.recycle_queued_shard_downloads(
1684                    downloader_idx,
1685                    shard_downloader_recycle_state.shard_download_queue_rx,
1686                )
1687                .await;
1688                Some(shard_downloader_recycle_state.recyclable_dedup_state)
1689            }
1690            Err(e) => {
1691                let ShardDownloaderError {
1692                    mut slot_scheduled_for_download,
1693                    shard_download_queue_rx,
1694                    recyclable_dedup_state,
1695                    kind,
1696                } = e;
1697                self.recycle_queued_shard_downloads(downloader_idx, shard_download_queue_rx)
1698                    .await;
1699
1700                match kind {
1701                    ShardDownloaderErrorKind::DataplaneError(dataplane_err) => {
1702                        if let Some(schedule_shard_download) =
1703                            slot_scheduled_for_download.pop_front()
1704                        {
1705                            let attempt = schedule_shard_download.attempt;
1706                            if dataplane_err.is_recoverable()
1707                                && attempt < self.max_download_attempt_per_slot
1708                            {
1709                                let queued = QueuedShardDownload {
1710                                    slot: schedule_shard_download.slot,
1711                                    request: DownloadBlockShard {
1712                                        blockchain_id: schedule_shard_download.blockchain_id,
1713                                        block_uid: schedule_shard_download.block_uid,
1714                                        shard_idx: schedule_shard_download.shard_idx as i32,
1715                                        block_filters: None,
1716                                        slot: Some(schedule_shard_download.slot),
1717                                    },
1718                                    attempt: attempt + 1,
1719                                };
1720                                self.route_queued_download_to_downloader(downloader_idx, queued);
1721                            } else {
1722                                self.recycle_scheduled_shard_downloads(
1723                                    downloader_idx,
1724                                    slot_scheduled_for_download,
1725                                )
1726                                .await;
1727                                return Err(DownloadBlockError::FailedDownload(dataplane_err));
1728                            }
1729                        }
1730                        self.recycle_scheduled_shard_downloads(
1731                            downloader_idx,
1732                            slot_scheduled_for_download,
1733                        )
1734                        .await;
1735                    }
1736                    ShardDownloaderErrorKind::SinkClosed => {
1737                        return Err(DownloadBlockError::OutletDisconnected);
1738                    }
1739                    ShardDownloaderErrorKind::SubscribeDataTxDisconnected => {
1740                        self.recycle_scheduled_shard_downloads(
1741                            downloader_idx,
1742                            slot_scheduled_for_download,
1743                        )
1744                        .await;
1745                    }
1746                }
1747                recyclable_dedup_state
1748            }
1749        };
1750
1751        self.spawn_shard_downloader(downloader_idx, recycled_dedup_state)
1752            .await
1753    }
1754
1755    async fn handle_control_command(&mut self, cmd: DownloadTaskRunnerCommand) {
1756        match cmd {
1757            DownloadTaskRunnerCommand::UpdateSubscribeRequest(subscribe_request) => {
1758                self.subscribe_request = subscribe_request;
1759                for handle in self.shard_downloader_handles.values() {
1760                    let _ = handle
1761                        .cnc_tx
1762                        .send(Arc::clone(&self.subscribe_request))
1763                        .await;
1764                }
1765            }
1766        }
1767    }
1768
1769    fn can_poll_download_task_queue(&self) -> bool {
1770        self.pending_shard_downloads.is_empty()
1771    }
1772
1773    pub(crate) async fn run(mut self) -> Result<(), DownloadBlockError> {
1774        for downloader_idx in 0..self.total_shard_downloaders {
1775            self.spawn_shard_downloader(downloader_idx, None).await?;
1776        }
1777
1778        while !self.outlet.is_closed() {
1779            self.drain_pending_shard_downloads();
1780            let can_poll_download_task = self.can_poll_download_task_queue();
1781            tokio::select! {
1782                maybe = self.cnc_rx.recv() => {
1783                    match maybe {
1784                        Some(cmd) => {
1785                            self.handle_control_command(cmd).await;
1786                        },
1787                        None => {
1788                            tracing::debug!("command channel disconnected");
1789                            break;
1790                        }
1791                    }
1792                }
1793                maybe_download_task = self.download_task_queue.recv(), if can_poll_download_task => {
1794                    match maybe_download_task {
1795                        Some(download_task) => {
1796                            self.schedule_slot_download_task(download_task).await;
1797                        }
1798                        None => {
1799                            tracing::debug!("download task queue disconnected");
1800                            break;
1801                        }
1802                    }
1803                }
1804                maybe = self.completed_rx.recv() => {
1805                    match maybe {
1806                        Some(completed) => {
1807                            self.handle_shard_download_completed(completed).await;
1808                        },
1809                        None => {
1810                            tracing::debug!("completed download channel disconnected");
1811                            break;
1812                        }
1813                    }
1814                }
1815                Some(result) = self.shard_downloader_js.join_next_with_id() => {
1816                    if result.is_err() && (self.outlet.is_closed() || self.cnc_rx.is_closed()) {
1817                        // When we do Ctrl+C or shutdown the runtime,
1818                        // the task runner will be closed and we will receive an error.
1819                        // We can safely ignore this error.
1820                        tracing::debug!("task runner closed");
1821                        break;
1822                    }
1823                    let (task_id, result) = result.expect("download task result");
1824                    self.handle_shard_downloader_result(task_id, result).await?;
1825                }
1826                _ = tokio::time::sleep(PENDING_SHARD_DOWNLOAD_RETRY_INTERVAL), if !self.pending_shard_downloads.is_empty() => {}
1827            }
1828        }
1829        tracing::debug!("Closing GrpcDownloadTaskRunner loop");
1830        Ok(())
1831    }
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836    use {
1837        super::*,
1838        futures::channel::mpsc::{self as futures_mpsc, TryRecvError},
1839        std::pin::Pin,
1840    };
1841
1842    #[derive(Clone, Default)]
1843    struct TestConnector;
1844
1845    impl FumaroleDataplaneConnector for TestConnector {
1846        type DataplaneSubscribeError = tonic::Status;
1847        type DataplaneSinkError = mpsc::error::SendError<DataCommand>;
1848        type DataplaneSink =
1849            Pin<Box<dyn Sink<DataCommand, Error = Self::DataplaneSinkError> + Send>>;
1850        type DataplaneStream =
1851            Pin<Box<dyn Stream<Item = Result<DataResponse, DataplaneStreamError>> + Send>>;
1852        type DataplaneSubscribeFut = Pin<
1853            Box<
1854                dyn Future<
1855                        Output = Result<
1856                            (Self::DataplaneSink, Self::DataplaneStream),
1857                            Self::DataplaneSubscribeError,
1858                        >,
1859                    > + Send,
1860            >,
1861        >;
1862
1863        fn subscribe_data(&self) -> Self::DataplaneSubscribeFut {
1864            Box::pin(async { Err(tonic::Status::unimplemented("test connector")) })
1865        }
1866    }
1867
1868    fn make_test_orchestrator() -> (
1869        ShardedDownloadOrchestrator<TestConnector>,
1870        mpsc::Receiver<CompletedDownloadBlockTask>,
1871        mpsc::Receiver<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
1872    ) {
1873        let (completed_tx, completed_rx) = mpsc::channel(8);
1874        let (cnc_tx, cnc_rx) = mpsc::channel(1);
1875        let (download_task_queue_tx, download_task_queue_rx) = mpsc::channel(1);
1876        let (outlet_tx, outlet_rx) = mpsc::channel(8);
1877        let (dragonsmouth_outlet_tx, dragonsmouth_outlet_rx) = mpsc::channel(8);
1878        let (shard_download_queue_tx, shard_download_queue_rx) =
1879            mpsc::channel(ORCHESTRATOR_DOWNLOADER_QUEUE_CAPACITY);
1880
1881        let orchestrator = ShardedDownloadOrchestrator {
1882            shard_download_queue_txs: vec![shard_download_queue_tx],
1883            shard_download_queue_rxs: vec![Some(shard_download_queue_rx)],
1884            slot_download_progression_map: HashMap::new(),
1885            completed_tx,
1886            completed_rx,
1887            connector: TestConnector,
1888            shard_downloader_js: JoinSet::new(),
1889            shard_downloader_handles: HashMap::new(),
1890            cnc_rx,
1891            download_task_queue: download_task_queue_rx,
1892            outlet: outlet_tx,
1893            max_download_attempt_per_slot: 3,
1894            subscribe_request: Arc::new(SubscribeRequest::default()),
1895            dragonsmouth_outlet: dragonsmouth_outlet_tx,
1896            total_shard_downloaders: 1,
1897            pending_shard_downloads: VecDeque::new(),
1898        };
1899
1900        drop(cnc_tx);
1901        drop(download_task_queue_tx);
1902
1903        (orchestrator, outlet_rx, dragonsmouth_outlet_rx)
1904    }
1905
1906    fn make_test_orchestrator_with_downloaders(
1907        total_shard_downloaders: usize,
1908    ) -> (
1909        ShardedDownloadOrchestrator<TestConnector>,
1910        mpsc::Receiver<CompletedDownloadBlockTask>,
1911        mpsc::Receiver<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
1912    ) {
1913        let (completed_tx, completed_rx) = mpsc::channel(8);
1914        let (cnc_tx, cnc_rx) = mpsc::channel(1);
1915        let (download_task_queue_tx, download_task_queue_rx) = mpsc::channel(1);
1916        let (outlet_tx, outlet_rx) = mpsc::channel(8);
1917        let (dragonsmouth_outlet_tx, dragonsmouth_outlet_rx) = mpsc::channel(8);
1918
1919        let mut shard_download_queue_txs = Vec::with_capacity(total_shard_downloaders);
1920        let mut shard_download_queue_rxs = Vec::with_capacity(total_shard_downloaders);
1921        for _ in 0..total_shard_downloaders {
1922            let (tx, rx) = mpsc::channel(ORCHESTRATOR_DOWNLOADER_QUEUE_CAPACITY);
1923            shard_download_queue_txs.push(tx);
1924            shard_download_queue_rxs.push(Some(rx));
1925        }
1926
1927        let orchestrator = ShardedDownloadOrchestrator {
1928            shard_download_queue_txs,
1929            shard_download_queue_rxs,
1930            slot_download_progression_map: HashMap::new(),
1931            completed_tx,
1932            completed_rx,
1933            connector: TestConnector,
1934            shard_downloader_js: JoinSet::new(),
1935            shard_downloader_handles: HashMap::new(),
1936            cnc_rx,
1937            download_task_queue: download_task_queue_rx,
1938            outlet: outlet_tx,
1939            max_download_attempt_per_slot: 3,
1940            subscribe_request: Arc::new(SubscribeRequest::default()),
1941            dragonsmouth_outlet: dragonsmouth_outlet_tx,
1942            total_shard_downloaders,
1943            pending_shard_downloads: VecDeque::new(),
1944        };
1945
1946        drop(cnc_tx);
1947        drop(download_task_queue_tx);
1948
1949        (orchestrator, outlet_rx, dragonsmouth_outlet_rx)
1950    }
1951
1952    fn mk_update(update_oneof: UpdateOneof) -> DataResponse {
1953        DataResponse {
1954            response: Some(data_response::Response::Update(SubscribeUpdate {
1955                filters: vec![],
1956                created_at: None,
1957                update_oneof: Some(update_oneof),
1958            })),
1959        }
1960    }
1961
1962    fn empty_scheduled_shard_rx() -> mpsc::UnboundedReceiver<(u64, FumeShardIdx)> {
1963        let (_tx, rx) = mpsc::unbounded_channel();
1964        rx
1965    }
1966
1967    #[tokio::test]
1968    async fn sharded_orchestrator_schedules_all_shards_and_dedups_slot() {
1969        let (mut orchestrator, _outlet_rx, _dragonsmouth_outlet_rx) = make_test_orchestrator();
1970
1971        let request = FumeDownloadRequest {
1972            slot: 77,
1973            blockchain_id: [11u8; 16],
1974            block_uid: [22u8; 16],
1975            num_shards: 3,
1976            commitment_level: CommitmentLevel::Processed,
1977        };
1978
1979        let mut queue_rx = orchestrator.shard_download_queue_rxs[0]
1980            .take()
1981            .expect("missing test queue receiver");
1982
1983        let drain_jh = tokio::spawn(async move {
1984            let mut drained = Vec::new();
1985            for _ in 0..3 {
1986                drained.push(queue_rx.recv().await.expect("expected queued shard"));
1987            }
1988            (drained, queue_rx)
1989        });
1990
1991        orchestrator
1992            .schedule_slot_download_task(DownloadTaskArgs {
1993                download_request: request.clone(),
1994            })
1995            .await;
1996
1997        // With bounded queue + pending queue fallback, actively drain pending items
1998        // so the spawned receiver can observe all 3 enqueued shards deterministically.
1999        for _ in 0..16 {
2000            if orchestrator.pending_shard_downloads.is_empty() {
2001                break;
2002            }
2003            orchestrator.drain_pending_shard_downloads();
2004            tokio::task::yield_now().await;
2005        }
2006
2007        let (drained, queue_rx) = drain_jh.await.expect("drain task should complete");
2008        orchestrator.shard_download_queue_rxs[0] = Some(queue_rx);
2009
2010        assert!(orchestrator.slot_download_progression_map.contains_key(&77));
2011        {
2012            for (shard_idx, queued) in drained.into_iter().enumerate() {
2013                assert_eq!(queued.slot, 77);
2014                assert_eq!(queued.attempt, 1);
2015                assert_eq!(queued.request.shard_idx, shard_idx as i32);
2016                assert_eq!(queued.request.slot, Some(77));
2017                assert_eq!(queued.request.blockchain_id, request.blockchain_id.to_vec());
2018                assert_eq!(queued.request.block_uid, request.block_uid.to_vec());
2019            }
2020        }
2021
2022        // Scheduling the same slot again should be ignored.
2023        orchestrator
2024            .schedule_slot_download_task(DownloadTaskArgs {
2025                download_request: request,
2026            })
2027            .await;
2028        {
2029            let queue_rx = orchestrator.shard_download_queue_rxs[0]
2030                .as_mut()
2031                .expect("missing test queue receiver");
2032            assert!(queue_rx.try_recv().is_err());
2033        }
2034    }
2035
2036    #[tokio::test]
2037    async fn sharded_orchestrator_emits_completion_and_block_meta_when_slot_finishes() {
2038        let (mut orchestrator, mut outlet_rx, mut dragonsmouth_outlet_rx) =
2039            make_test_orchestrator();
2040
2041        orchestrator.slot_download_progression_map.insert(
2042            42,
2043            ShardedSlotDownloadProgress {
2044                started_at: Instant::now(),
2045                block_meta: None,
2046                remaining_shard_idx: vec![0],
2047            },
2048        );
2049
2050        let completed = CompletedDownloadBlockShardTask {
2051            shard_idx: 0,
2052            block_uid: [7u8; 16],
2053            block_meta: Some(SubscribeUpdate {
2054                filters: vec![],
2055                created_at: None,
2056                update_oneof: Some(UpdateOneof::BlockMeta(
2057                    yellowstone_grpc_proto::geyser::SubscribeUpdateBlockMeta::default(),
2058                )),
2059            }),
2060            slot: 42,
2061        };
2062
2063        orchestrator
2064            .handle_shard_download_completed(completed)
2065            .await;
2066
2067        let task = outlet_rx
2068            .recv()
2069            .await
2070            .expect("expected completed task result");
2071        assert_eq!(task.slot, 42);
2072        assert_eq!(task.block_uid, [7u8; 16]);
2073        assert_eq!(task.shard_idx_vec, vec![0]);
2074
2075        let dm_msg = dragonsmouth_outlet_rx
2076            .recv()
2077            .await
2078            .expect("expected dragonsmouth block meta");
2079        let Ok(FumaroleRuntimeEvent::Data(FumaroleRuntimeDataEvent {
2080            slot: 42,
2081            update: dm_update,
2082        })) = dm_msg
2083        else {
2084            panic!("expected dragonsmouth data event")
2085        };
2086        assert!(matches!(
2087            dm_update.update_oneof,
2088            Some(UpdateOneof::BlockMeta(_))
2089        ));
2090        let slot_ended = dragonsmouth_outlet_rx
2091            .recv()
2092            .await
2093            .expect("expected slot ended event");
2094        assert!(matches!(
2095            slot_ended,
2096            Ok(FumaroleRuntimeEvent::SlotEnded(42))
2097        ));
2098        assert!(!orchestrator.slot_download_progression_map.contains_key(&42));
2099    }
2100
2101    #[tokio::test]
2102    async fn sharded_orchestrator_emits_slot_ended_without_block_meta_when_slot_finishes() {
2103        let (mut orchestrator, mut outlet_rx, mut dragonsmouth_outlet_rx) =
2104            make_test_orchestrator();
2105
2106        orchestrator.slot_download_progression_map.insert(
2107            43,
2108            ShardedSlotDownloadProgress {
2109                started_at: Instant::now(),
2110                block_meta: None,
2111                remaining_shard_idx: vec![0],
2112            },
2113        );
2114
2115        let completed = CompletedDownloadBlockShardTask {
2116            shard_idx: 0,
2117            block_uid: [8u8; 16],
2118            block_meta: None,
2119            slot: 43,
2120        };
2121
2122        orchestrator
2123            .handle_shard_download_completed(completed)
2124            .await;
2125
2126        let task = outlet_rx
2127            .recv()
2128            .await
2129            .expect("expected completed task result");
2130        assert_eq!(task.slot, 43);
2131        assert_eq!(task.block_uid, [8u8; 16]);
2132        assert_eq!(task.shard_idx_vec, vec![0]);
2133
2134        let slot_ended = dragonsmouth_outlet_rx
2135            .recv()
2136            .await
2137            .expect("expected slot ended event");
2138        assert!(matches!(
2139            slot_ended,
2140            Ok(FumaroleRuntimeEvent::SlotEnded(43))
2141        ));
2142        assert!(dragonsmouth_outlet_rx.try_recv().is_err());
2143        assert!(!orchestrator.slot_download_progression_map.contains_key(&43));
2144    }
2145
2146    #[tokio::test]
2147    async fn sharded_orchestrator_recycles_failed_downloader_back_to_same_queue() {
2148        let (mut orchestrator, _outlet_rx, _dragonsmouth_outlet_rx) =
2149            make_test_orchestrator_with_downloaders(2);
2150
2151        let failed_downloader_idx = 1usize;
2152
2153        let ah = orchestrator.shard_downloader_js.spawn(async {
2154            std::future::pending::<Result<ShardDownloaderRecycleState, ShardDownloaderError>>()
2155                .await
2156        });
2157        let task_id = ah.id();
2158
2159        let (cnc_tx, _cnc_rx) = mpsc::channel(1);
2160        orchestrator.shard_downloader_handles.insert(
2161            task_id,
2162            PipelinedShardDownloaderHandle {
2163                downloader_idx: failed_downloader_idx,
2164                cnc_tx,
2165            },
2166        );
2167
2168        let mut scheduled_shard_downloads = VecDeque::new();
2169        scheduled_shard_downloads.push_back(ScheduleShardDownload {
2170            blockchain_id: vec![1; 16],
2171            shard_idx: 3,
2172            block_uid: vec![2; 16],
2173            attempt: 1,
2174            slot: 55,
2175        });
2176
2177        let (recycled_tx, recycled_rx) = mpsc::channel(2);
2178        recycled_tx
2179            .send(QueuedShardDownload {
2180                slot: 56,
2181                request: DownloadBlockShard {
2182                    blockchain_id: vec![3; 16],
2183                    block_uid: vec![4; 16],
2184                    shard_idx: 5,
2185                    block_filters: None,
2186                    slot: Some(56),
2187                },
2188                attempt: 2,
2189            })
2190            .await
2191            .expect("recycle queue send should succeed");
2192        drop(recycled_tx);
2193
2194        let err = ShardDownloaderError {
2195            slot_scheduled_for_download: scheduled_shard_downloads,
2196            shard_download_queue_rx: recycled_rx,
2197            recyclable_dedup_state: Some(DedupState::default()),
2198            kind: ShardDownloaderErrorKind::SubscribeDataTxDisconnected,
2199        };
2200
2201        let result = orchestrator
2202            .handle_shard_downloader_result(task_id, Err(err))
2203            .await;
2204        assert!(result.is_err());
2205
2206        // Both recycled tasks (shard 3 and 5 => 1 mod 2) should go to downloader 1.
2207        let queue_1 = orchestrator.shard_download_queue_rxs[1]
2208            .as_mut()
2209            .expect("downloader 1 queue receiver should exist after reset");
2210        let first = queue_1
2211            .try_recv()
2212            .expect("expected first recycled task on downloader 1");
2213        let second = queue_1
2214            .try_recv()
2215            .expect("expected second recycled task on downloader 1");
2216        assert!(matches!(first.request.shard_idx, 3 | 5));
2217        assert!(matches!(second.request.shard_idx, 3 | 5));
2218        assert_ne!(first.request.shard_idx, second.request.shard_idx);
2219
2220        let queue_0 = orchestrator.shard_download_queue_rxs[0]
2221            .as_mut()
2222            .expect("downloader 0 queue receiver should exist");
2223        assert!(queue_0.try_recv().is_err());
2224    }
2225
2226    #[tokio::test]
2227    async fn pipelined_downloader_forwards_updates_and_reports_completed_shard() {
2228        let (outlet_tx, mut outlet_rx) =
2229            futures_mpsc::unbounded::<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>();
2230        let (completed_tx, mut completed_rx) = mpsc::channel(2);
2231
2232        let footer = proto::BlockShardDownloadFinish {
2233            block_uid: vec![7u8; 16],
2234            slot: 42,
2235            shard_indices: vec![3],
2236        };
2237
2238        let stream = tokio_stream::iter(vec![
2239            Ok(mk_update(UpdateOneof::Slot(SubscribeUpdateSlot {
2240                slot: 42,
2241                ..SubscribeUpdateSlot::default()
2242            }))),
2243            Ok(mk_update(UpdateOneof::BlockMeta(
2244                yellowstone_grpc_proto::geyser::SubscribeUpdateBlockMeta::default(),
2245            ))),
2246            Ok(DataResponse {
2247                response: Some(data_response::Response::BlockShardDownloadFinish(footer)),
2248            }),
2249        ]);
2250
2251        let result = PipelinedShardDownloader::pipelined_downloader(
2252            stream,
2253            outlet_tx,
2254            completed_tx,
2255            DedupState::default(),
2256            empty_scheduled_shard_rx(),
2257        )
2258        .await;
2259
2260        assert!(matches!(result, Ok((None, _))));
2261
2262        let forwarded = outlet_rx.next().await.expect("expected forwarded update");
2263        let Ok(FumaroleRuntimeEvent::Data(FumaroleRuntimeDataEvent {
2264            slot: 42,
2265            update: forwarded,
2266        })) = forwarded
2267        else {
2268            panic!("expected data event")
2269        };
2270        assert!(matches!(forwarded.update_oneof, Some(UpdateOneof::Slot(_))));
2271        assert!(matches!(outlet_rx.try_recv(), Err(TryRecvError::Closed)));
2272
2273        let completed = completed_rx
2274            .recv()
2275            .await
2276            .expect("expected completion footer");
2277        assert_eq!(completed.shard_idx, 3);
2278        assert_eq!(completed.slot, 42);
2279        assert_eq!(completed.block_uid, [7u8; 16]);
2280        assert!(matches!(
2281            completed.block_meta.and_then(|u| u.update_oneof),
2282            Some(UpdateOneof::BlockMeta(_))
2283        ));
2284    }
2285
2286    #[tokio::test]
2287    async fn pipelined_downloader_returns_sink_closed_when_outlet_is_disconnected() {
2288        let (outlet_tx, outlet_rx) =
2289            futures_mpsc::unbounded::<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>();
2290        drop(outlet_rx);
2291        let (completed_tx, _completed_rx) = mpsc::channel(1);
2292
2293        let stream = tokio_stream::iter(vec![Ok(mk_update(UpdateOneof::Slot(
2294            SubscribeUpdateSlot::default(),
2295        )))]);
2296
2297        let result = PipelinedShardDownloader::<
2298            futures_mpsc::UnboundedSender<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
2299        >::pipelined_downloader(
2300            stream,
2301            outlet_tx,
2302            completed_tx,
2303            DedupState::default(),
2304            empty_scheduled_shard_rx(),
2305        )
2306        .await;
2307
2308        assert!(matches!(
2309            result,
2310            Err((ShardDownloaderErrorKind::SinkClosed, _))
2311        ));
2312    }
2313
2314    #[tokio::test]
2315    async fn dedup_state_drops_duplicate_updates() {
2316        let mut dedup_state = DedupState::default();
2317        let update = UpdateOneof::Entry(yellowstone_grpc_proto::geyser::SubscribeUpdateEntry {
2318            slot: 42,
2319            index: 7,
2320            num_hashes: 0,
2321            hash: Vec::new(),
2322            executed_transaction_count: 0,
2323            starting_transaction_index: 0,
2324        });
2325
2326        assert!(!dedup_state.dedup(42, 0, &update));
2327        assert!(dedup_state.dedup(42, 0, &update));
2328
2329        dedup_state.mark_shard_done(42, 0);
2330        assert!(dedup_state.is_shard_done(42, 0));
2331
2332        // Once shard is marked done, associated keys are released.
2333        assert!(!dedup_state.dedup(42, 1, &update));
2334    }
2335
2336    #[tokio::test]
2337    async fn dedup_state_skips_non_keyed_updates_by_default() {
2338        let mut dedup_state = DedupState::default();
2339        let update = UpdateOneof::Slot(SubscribeUpdateSlot {
2340            slot: 42,
2341            parent: Some(41),
2342            status: 1,
2343            dead_error: Some(String::new()),
2344        });
2345
2346        assert!(dedup_state.dedup(42, 0, &update));
2347    }
2348
2349    #[tokio::test]
2350    async fn pipelined_downloader_propagates_recoverable_transport_error() {
2351        let (outlet_tx, _outlet_rx) =
2352            futures_mpsc::unbounded::<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>();
2353        let (completed_tx, _completed_rx) = mpsc::channel(1);
2354
2355        let stream = tokio_stream::iter(vec![Err(DataplaneStreamError::from(
2356            tonic::Status::unavailable("connection dropped"),
2357        ))]);
2358
2359        let result = PipelinedShardDownloader::<
2360            futures_mpsc::UnboundedSender<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
2361        >::pipelined_downloader(
2362            stream,
2363            outlet_tx,
2364            completed_tx,
2365            DedupState::default(),
2366            empty_scheduled_shard_rx(),
2367        )
2368        .await;
2369
2370        match result {
2371            Err((ShardDownloaderErrorKind::DataplaneError(err), _)) => {
2372                assert_eq!(err.kind(), DataplaneErrorKind::RecoverableTransport);
2373                assert!(err.is_recoverable());
2374            }
2375            _ => panic!("expected dataplane recoverable transport error"),
2376        }
2377    }
2378
2379    #[tokio::test]
2380    async fn pipelined_downloader_propagates_slot_not_found_error() {
2381        let (outlet_tx, _outlet_rx) =
2382            futures_mpsc::unbounded::<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>();
2383        let (completed_tx, _completed_rx) = mpsc::channel(1);
2384
2385        let stream = tokio_stream::iter(vec![Err(DataplaneStreamError::from(
2386            tonic::Status::not_found("slot not found"),
2387        ))]);
2388
2389        let result = PipelinedShardDownloader::<
2390            futures_mpsc::UnboundedSender<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
2391        >::pipelined_downloader(
2392            stream,
2393            outlet_tx,
2394            completed_tx,
2395            DedupState::default(),
2396            empty_scheduled_shard_rx(),
2397        )
2398        .await;
2399
2400        match result {
2401            Err((ShardDownloaderErrorKind::DataplaneError(err), _)) => {
2402                assert_eq!(err.kind(), DataplaneErrorKind::SlotNotFound);
2403                assert!(!err.is_recoverable());
2404            }
2405            _ => panic!("expected dataplane slot-not-found error"),
2406        }
2407    }
2408
2409    #[tokio::test]
2410    async fn pipelined_downloader_propagates_invalid_subscribe_filter_error() {
2411        let (outlet_tx, _outlet_rx) =
2412            futures_mpsc::unbounded::<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>();
2413        let (completed_tx, _completed_rx) = mpsc::channel(1);
2414
2415        let stream = tokio_stream::iter(vec![Err(DataplaneStreamError::from(
2416            tonic::Status::invalid_argument("invalid filter expression"),
2417        ))]);
2418
2419        let result = PipelinedShardDownloader::<
2420            futures_mpsc::UnboundedSender<Result<FumaroleRuntimeEvent, FumaroleSubscribeError>>,
2421        >::pipelined_downloader(
2422            stream,
2423            outlet_tx,
2424            completed_tx,
2425            DedupState::default(),
2426            empty_scheduled_shard_rx(),
2427        )
2428        .await;
2429
2430        match result {
2431            Err((ShardDownloaderErrorKind::DataplaneError(err), _)) => {
2432                assert_eq!(err.kind(), DataplaneErrorKind::InvalidSubscribeFilter);
2433                assert!(!err.is_recoverable());
2434            }
2435            _ => panic!("expected dataplane invalid-filter error"),
2436        }
2437    }
2438}