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 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
74pub 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
88pub(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, 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 PollBlockchainHistory {
133 shard_id: 0, 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, },
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 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 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.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 }
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
670pub struct DownloadTaskRunnerChannels {
678 pub download_task_queue_tx: mpsc::Sender<DownloadTaskArgs>,
682
683 pub cnc_tx: mpsc::Sender<DownloadTaskRunnerCommand>,
687
688 pub download_result_rx: mpsc::Receiver<CompletedDownloadBlockTask>,
691}
692
693pub enum DownloadTaskRunnerCommand {
694 UpdateSubscribeRequest(Arc<SubscribeRequest>),
695}
696
697#[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 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 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 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 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 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
1274pub(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#[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 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 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 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 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 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 {
1556 #[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 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 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 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 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 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 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 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 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 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}