1#![deny(missing_docs)]
2pub mod metrics;
122pub mod plugins;
124
125const LOG_MODULE: &str = "jetstreamer::runner";
126
127use std::{
128 fmt::Display,
129 future::Future,
130 hint,
131 ops::Range,
132 pin::Pin,
133 sync::{
134 Arc,
135 atomic::{AtomicBool, AtomicU64, Ordering},
136 },
137 time::Duration,
138};
139
140use clickhouse::{Client, Row};
141use dashmap::DashMap;
142use futures_util::FutureExt;
143use jetstreamer_firehose::firehose::{
144 BlockData, EntryData, RewardsData, Stats, StatsTracking, TransactionData, firehose,
145};
146use once_cell::sync::Lazy;
147use serde::Serialize;
148use sha2::{Digest, Sha256};
149use thiserror::Error;
150use tokio::{signal, sync::broadcast};
151use url::Url;
152
153pub use jetstreamer_firehose::firehose::{
155 FirehoseErrorContext, Stats as FirehoseStats, ThreadStats,
156};
157
158static LAST_TOTAL_SLOTS: AtomicU64 = AtomicU64::new(0);
160static LAST_TOTAL_TXS: AtomicU64 = AtomicU64::new(0);
161static LAST_TOTAL_TIME_NS: AtomicU64 = AtomicU64::new(0);
162static SNAPSHOT_LOCK: AtomicBool = AtomicBool::new(false);
163#[inline]
164fn monotonic_nanos_since(origin: std::time::Instant) -> u64 {
165 origin.elapsed().as_nanos() as u64
166}
167
168pub type PluginFuture<'a> = Pin<
170 Box<
171 dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync + 'static>>>
172 + Send
173 + 'a,
174 >,
175>;
176
177pub trait Plugin: Send + Sync + 'static {
181 fn name(&self) -> &'static str;
183
184 fn version(&self) -> u16 {
186 1
187 }
188
189 fn id(&self) -> u16 {
191 let hash = Sha256::digest(self.name());
192 let mut res = 1u16;
193 for byte in hash {
194 res = res.wrapping_mul(31).wrapping_add(byte as u16);
195 }
196 res
197 }
198
199 fn on_transaction<'a>(
201 &'a self,
202 _thread_id: usize,
203 _db: Option<Arc<Client>>,
204 _transaction: &'a TransactionData,
205 ) -> PluginFuture<'a> {
206 async move { Ok(()) }.boxed()
207 }
208
209 fn on_block<'a>(
211 &'a self,
212 _thread_id: usize,
213 _db: Option<Arc<Client>>,
214 _block: &'a BlockData,
215 ) -> PluginFuture<'a> {
216 async move { Ok(()) }.boxed()
217 }
218
219 fn on_entry<'a>(
221 &'a self,
222 _thread_id: usize,
223 _db: Option<Arc<Client>>,
224 _entry: &'a EntryData,
225 ) -> PluginFuture<'a> {
226 async move { Ok(()) }.boxed()
227 }
228
229 fn on_reward<'a>(
231 &'a self,
232 _thread_id: usize,
233 _db: Option<Arc<Client>>,
234 _reward: &'a RewardsData,
235 ) -> PluginFuture<'a> {
236 async move { Ok(()) }.boxed()
237 }
238
239 fn on_error<'a>(
241 &'a self,
242 _thread_id: usize,
243 _db: Option<Arc<Client>>,
244 _error: &'a FirehoseErrorContext,
245 ) -> PluginFuture<'a> {
246 async move { Ok(()) }.boxed()
247 }
248
249 fn on_load(&self, _db: Option<Arc<Client>>) -> PluginFuture<'_> {
251 async move { Ok(()) }.boxed()
252 }
253
254 fn on_exit(&self, _db: Option<Arc<Client>>) -> PluginFuture<'_> {
256 async move { Ok(()) }.boxed()
257 }
258}
259
260#[derive(Clone)]
264pub struct PluginRunner {
265 plugins: Arc<Vec<Arc<dyn Plugin>>>,
266 clickhouse_dsn: String,
267 num_threads: usize,
268 sequential: bool,
269 reverse: bool,
270 buffer_window_bytes: Option<u64>,
271 db_update_interval_slots: u64,
272 tui: bool,
273}
274
275impl PluginRunner {
276 pub fn new(
283 clickhouse_dsn: impl Display,
284 num_threads: usize,
285 sequential: bool,
286 reverse: bool,
287 buffer_window_bytes: Option<u64>,
288 ) -> Self {
289 Self {
290 plugins: Arc::new(Vec::new()),
291 clickhouse_dsn: clickhouse_dsn.to_string(),
292 num_threads: std::cmp::max(1, num_threads),
293 sequential,
294 reverse,
295 buffer_window_bytes,
296 db_update_interval_slots: 100,
297 tui: false,
298 }
299 }
300
301 pub fn set_tui(&mut self, tui: bool) {
304 self.tui = tui;
305 }
306
307 pub fn register(&mut self, plugin: Box<dyn Plugin>) {
309 Arc::get_mut(&mut self.plugins)
310 .expect("cannot register plugins after the runner has started")
311 .push(Arc::from(plugin));
312 }
313
314 pub async fn run(
316 self: Arc<Self>,
317 slot_range: Range<u64>,
318 clickhouse_enabled: bool,
319 ) -> Result<(), PluginRunnerError> {
320 let db_update_interval = self.db_update_interval_slots.max(1);
321 let plugin_handles: Arc<Vec<PluginHandle>> = Arc::new(
322 self.plugins
323 .iter()
324 .cloned()
325 .map(PluginHandle::from)
326 .collect(),
327 );
328
329 let clickhouse = if clickhouse_enabled {
330 let client = Arc::new(
331 build_clickhouse_client(&self.clickhouse_dsn)
332 .with_setting("async_insert", "1")
333 .with_setting("wait_for_async_insert", "1"),
338 );
339 ensure_clickhouse_tables(client.as_ref()).await?;
340 upsert_plugins(client.as_ref(), plugin_handles.as_ref()).await?;
341 Some(client)
342 } else {
343 None
344 };
345
346 for handle in plugin_handles.iter() {
347 if let Err(error) = handle
348 .plugin
349 .on_load(clickhouse.clone())
350 .await
351 .map_err(|e| e.to_string())
352 {
353 return Err(PluginRunnerError::PluginLifecycle {
354 plugin: handle.name,
355 stage: "on_load",
356 details: error,
357 });
358 }
359 }
360
361 let shutting_down = Arc::new(AtomicBool::new(false));
362 let slot_buffer: Arc<DashMap<u16, Vec<PluginSlotRow>, ahash::RandomState>> =
363 Arc::new(DashMap::with_hasher(ahash::RandomState::new()));
364 let clickhouse_enabled = clickhouse.is_some();
365 let slots_since_flush = Arc::new(AtomicU64::new(0));
366
367 let on_block = {
368 let plugin_handles = plugin_handles.clone();
369 let clickhouse = clickhouse.clone();
370 let slot_buffer = slot_buffer.clone();
371 let slots_since_flush = slots_since_flush.clone();
372 let shutting_down = shutting_down.clone();
373 move |thread_id: usize, block: BlockData| {
374 let plugin_handles = plugin_handles.clone();
375 let clickhouse = clickhouse.clone();
376 let slot_buffer = slot_buffer.clone();
377 let slots_since_flush = slots_since_flush.clone();
378 let shutting_down = shutting_down.clone();
379 async move {
380 let log_target = format!("{}::T{:03}", LOG_MODULE, thread_id);
381 metrics::note_thread_activity(thread_id);
382 if shutting_down.load(Ordering::SeqCst) {
383 log::debug!(
384 target: &log_target,
385 "ignoring block while shutdown is in progress"
386 );
387 return Ok(());
388 }
389 let block = Arc::new(block);
390 if !plugin_handles.is_empty() {
391 for handle in plugin_handles.iter() {
392 let db = clickhouse.clone();
393 if let Err(err) = handle
394 .plugin
395 .on_block(thread_id, db.clone(), block.as_ref())
396 .await
397 {
398 log::error!(
399 target: &log_target,
400 "plugin {} on_block error: {}",
401 handle.name,
402 err
403 );
404 continue;
405 }
406 if let (Some(db_client), BlockData::Block { slot, .. }) =
407 (clickhouse.clone(), block.as_ref())
408 {
409 if clickhouse_enabled {
410 slot_buffer
411 .entry(handle.id)
412 .or_default()
413 .push(PluginSlotRow {
414 plugin_id: handle.id as u32,
415 slot: *slot,
416 });
417 } else if let Err(err) =
418 record_plugin_slot(db_client, handle.id, *slot).await
419 {
420 log::error!(
421 target: &log_target,
422 "failed to record plugin slot for {}: {}",
423 handle.name,
424 err
425 );
426 }
427 }
428 }
429 if clickhouse_enabled {
430 let current = slots_since_flush
431 .fetch_add(1, Ordering::Relaxed)
432 .wrapping_add(1);
433 if current.is_multiple_of(db_update_interval)
434 && let Some(db_client) = clickhouse.clone()
435 {
436 let buffer = slot_buffer.clone();
437 spawn_tracked_write(async move {
438 flush_slot_buffer(db_client, buffer).await;
439 });
440 }
441 }
442 }
443 if let Some(db_client) = clickhouse.clone() {
444 match block.as_ref() {
445 BlockData::Block {
446 slot,
447 executed_transaction_count,
448 block_time,
449 ..
450 } => {
451 let tally = take_slot_tx_tally(*slot);
452 let slot = *slot;
453 let executed_transaction_count = *executed_transaction_count;
454 let block_time = *block_time;
455 spawn_tracked_write(async move {
456 retry_clickhouse_write("slot status", || {
457 record_slot_status(
458 Arc::clone(&db_client),
459 slot,
460 thread_id,
461 executed_transaction_count,
462 tally.votes,
463 tally.non_votes,
464 block_time,
465 )
466 })
467 .await;
468 });
469 }
470 BlockData::PossibleLeaderSkipped { slot } => {
471 take_slot_tx_tally(*slot);
473 }
474 }
475 }
476 Ok(())
477 }
478 .boxed()
479 }
480 };
481
482 let on_transaction = {
483 let plugin_handles = plugin_handles.clone();
484 let clickhouse = clickhouse.clone();
485 let shutting_down = shutting_down.clone();
486 move |thread_id: usize, transaction: TransactionData| {
487 let plugin_handles = plugin_handles.clone();
488 let clickhouse = clickhouse.clone();
489 let shutting_down = shutting_down.clone();
490 async move {
491 let log_target = format!("{}::T{:03}", LOG_MODULE, thread_id);
492 metrics::note_thread_transaction(thread_id);
493 record_slot_vote_tally(transaction.slot, transaction.is_vote);
494 if plugin_handles.is_empty() {
495 return Ok(());
496 }
497 if shutting_down.load(Ordering::SeqCst) {
498 log::debug!(
499 target: &log_target,
500 "ignoring transaction while shutdown is in progress"
501 );
502 return Ok(());
503 }
504 for handle in plugin_handles.iter() {
505 if let Err(err) = handle
506 .plugin
507 .on_transaction(thread_id, clickhouse.clone(), &transaction)
508 .await
509 {
510 log::error!(
511 target: &log_target,
512 "plugin {} on_transaction error: {}",
513 handle.name,
514 err
515 );
516 }
517 }
518 Ok(())
519 }
520 .boxed()
521 }
522 };
523
524 let on_entry = {
525 let plugin_handles = plugin_handles.clone();
526 let clickhouse = clickhouse.clone();
527 let shutting_down = shutting_down.clone();
528 move |thread_id: usize, entry: EntryData| {
529 let plugin_handles = plugin_handles.clone();
530 let clickhouse = clickhouse.clone();
531 let shutting_down = shutting_down.clone();
532 async move {
533 let log_target = format!("{}::T{:03}", LOG_MODULE, thread_id);
534 if plugin_handles.is_empty() {
535 return Ok(());
536 }
537 if shutting_down.load(Ordering::SeqCst) {
538 log::debug!(
539 target: &log_target,
540 "ignoring entry while shutdown is in progress"
541 );
542 return Ok(());
543 }
544 let entry = Arc::new(entry);
545 for handle in plugin_handles.iter() {
546 if let Err(err) = handle
547 .plugin
548 .on_entry(thread_id, clickhouse.clone(), entry.as_ref())
549 .await
550 {
551 log::error!(
552 target: &log_target,
553 "plugin {} on_entry error: {}",
554 handle.name,
555 err
556 );
557 }
558 }
559 Ok(())
560 }
561 .boxed()
562 }
563 };
564
565 let on_reward = {
566 let plugin_handles = plugin_handles.clone();
567 let clickhouse = clickhouse.clone();
568 let shutting_down = shutting_down.clone();
569 move |thread_id: usize, reward: RewardsData| {
570 let plugin_handles = plugin_handles.clone();
571 let clickhouse = clickhouse.clone();
572 let shutting_down = shutting_down.clone();
573 async move {
574 let log_target = format!("{}::T{:03}", LOG_MODULE, thread_id);
575 if plugin_handles.is_empty() {
576 return Ok(());
577 }
578 if shutting_down.load(Ordering::SeqCst) {
579 log::debug!(
580 target: &log_target,
581 "ignoring reward while shutdown is in progress"
582 );
583 return Ok(());
584 }
585 let reward = Arc::new(reward);
586 for handle in plugin_handles.iter() {
587 if let Err(err) = handle
588 .plugin
589 .on_reward(thread_id, clickhouse.clone(), reward.as_ref())
590 .await
591 {
592 log::error!(
593 target: &log_target,
594 "plugin {} on_reward error: {}",
595 handle.name,
596 err
597 );
598 }
599 }
600 Ok(())
601 }
602 .boxed()
603 }
604 };
605
606 let on_error = {
607 let plugin_handles = plugin_handles.clone();
608 let clickhouse = clickhouse.clone();
609 let shutting_down = shutting_down.clone();
610 move |thread_id: usize, context: FirehoseErrorContext| {
611 let plugin_handles = plugin_handles.clone();
612 let clickhouse = clickhouse.clone();
613 let shutting_down = shutting_down.clone();
614 async move {
615 let log_target = format!("{}::T{:03}", LOG_MODULE, thread_id);
616 if plugin_handles.is_empty() {
617 return Ok(());
618 }
619 if shutting_down.load(Ordering::SeqCst) {
620 log::debug!(
621 target: &log_target,
622 "ignoring error callback while shutdown is in progress"
623 );
624 return Ok(());
625 }
626 let context = Arc::new(context);
627 for handle in plugin_handles.iter() {
628 if let Err(err) = handle
629 .plugin
630 .on_error(thread_id, clickhouse.clone(), context.as_ref())
631 .await
632 {
633 log::error!(
634 target: &log_target,
635 "plugin {} on_error error: {}",
636 handle.name,
637 err
638 );
639 }
640 }
641 Ok(())
642 }
643 .boxed()
644 }
645 };
646
647 let total_slot_count = slot_range.end.saturating_sub(slot_range.start);
648
649 let total_slot_count_capture = total_slot_count;
650 let run_origin = std::time::Instant::now();
651 SNAPSHOT_LOCK.store(false, Ordering::Relaxed);
653 LAST_TOTAL_SLOTS.store(0, Ordering::Relaxed);
654 LAST_TOTAL_TXS.store(0, Ordering::Relaxed);
655 LAST_TOTAL_TIME_NS.store(monotonic_nanos_since(run_origin), Ordering::Relaxed);
656 metrics::init(if self.sequential { 1 } else { self.num_threads });
657 metrics::set_run_slot_range(slot_range.start, slot_range.end);
658 let stats_tracking = (clickhouse.is_some() || self.tui).then(|| {
661 let shutting_down = shutting_down.clone();
662 let thread_progress_max: Arc<DashMap<usize, f64, ahash::RandomState>> = Arc::new(DashMap::with_hasher(ahash::RandomState::new()));
663 StatsTracking {
664 on_stats: {
665 let thread_progress_max = thread_progress_max.clone();
666 let total_slot_count = total_slot_count_capture;
667 move |thread_id: usize, stats: Stats| {
668 let shutting_down = shutting_down.clone();
669 let thread_progress_max = thread_progress_max.clone();
670 async move {
671 let log_target = format!("{}::T{:03}", LOG_MODULE, thread_id);
672 if shutting_down.load(Ordering::SeqCst) {
673 log::debug!(
674 target: &log_target,
675 "skipping stats write during shutdown"
676 );
677 return Ok(());
678 }
679 let finish_at = stats
680 .finish_time
681 .unwrap_or_else(std::time::Instant::now);
682 let elapsed_since_start = finish_at
683 .saturating_duration_since(stats.start_time)
684 .as_nanos()
685 .max(1) as u64;
686 let total_slots = stats.slots_processed;
687 let total_txs = stats.transactions_processed;
688 let now_ns = monotonic_nanos_since(run_origin);
689 let (delta_slots, delta_txs, delta_time_ns) = {
693 while SNAPSHOT_LOCK
694 .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
695 .is_err()
696 {
697 hint::spin_loop();
698 }
699 let prev_slots = LAST_TOTAL_SLOTS.load(Ordering::Relaxed);
700 let prev_txs = LAST_TOTAL_TXS.load(Ordering::Relaxed);
701 let prev_time_ns = LAST_TOTAL_TIME_NS.load(Ordering::Relaxed);
702 LAST_TOTAL_SLOTS.store(total_slots, Ordering::Relaxed);
703 LAST_TOTAL_TXS.store(total_txs, Ordering::Relaxed);
704 LAST_TOTAL_TIME_NS.store(now_ns, Ordering::Relaxed);
705 SNAPSHOT_LOCK.store(false, Ordering::Release);
706 let delta_slots = total_slots.saturating_sub(prev_slots);
707 let delta_txs = total_txs.saturating_sub(prev_txs);
708 let delta_time_ns = now_ns.saturating_sub(prev_time_ns).max(1);
709 (delta_slots, delta_txs, delta_time_ns)
710 };
711 let delta_secs = (delta_time_ns as f64 / 1e9).max(1e-9);
712 let mut slot_rate = delta_slots as f64 / delta_secs;
713 let mut tps = delta_txs as f64 / delta_secs;
714 if slot_rate <= 0.0 && total_slots > 0 {
715 slot_rate =
716 total_slots as f64 / (elapsed_since_start as f64 / 1e9);
717 }
718 if tps <= 0.0 && total_txs > 0 {
719 tps = total_txs as f64 / (elapsed_since_start as f64 / 1e9);
720 }
721 let thread_stats = &stats.thread_stats;
722 let processed_slots = stats.slots_processed.min(total_slot_count);
723 let progress_fraction = if total_slot_count > 0 {
724 processed_slots as f64 / total_slot_count as f64
725 } else {
726 1.0
727 };
728 let overall_progress = (progress_fraction * 100.0).clamp(0.0, 100.0);
729 let thread_total_slots = thread_stats
730 .initial_slot_range
731 .end
732 .saturating_sub(thread_stats.initial_slot_range.start);
733 let thread_progress_raw = if thread_total_slots > 0 {
734 (thread_stats.slots_processed as f64 / thread_total_slots as f64)
735 .clamp(0.0, 1.0)
736 * 100.0
737 } else {
738 100.0
739 };
740 let thread_progress = *thread_progress_max
741 .entry(thread_id)
742 .and_modify(|max| {
743 if thread_progress_raw > *max {
744 *max = thread_progress_raw;
745 }
746 })
747 .or_insert(thread_progress_raw);
748 let mut overall_eta = None;
749 if slot_rate > 0.0 {
750 let remaining_slots =
751 total_slot_count.saturating_sub(processed_slots);
752 overall_eta = Some(human_readable_duration(
753 remaining_slots as f64 / slot_rate,
754 ));
755 }
756 if overall_eta.is_none() {
757 if progress_fraction > 0.0 && progress_fraction < 1.0 {
758 if let Some(elapsed_total) = finish_at
759 .checked_duration_since(stats.start_time)
760 .map(|d| d.as_secs_f64())
761 && elapsed_total > 0.0 {
762 let remaining_secs =
763 elapsed_total * (1.0 / progress_fraction - 1.0);
764 overall_eta = Some(human_readable_duration(remaining_secs));
765 }
766 } else if progress_fraction >= 1.0 {
767 overall_eta = Some("0s".into());
768 }
769 }
770 metrics::record_pulse(metrics::PulseSnapshot {
771 progress_pct: overall_progress,
772 eta: overall_eta.clone(),
773 tps,
774 slots_processed: processed_slots,
775 blocks_processed: stats.blocks_processed,
776 transactions_processed: stats.transactions_processed,
777 entries_processed: stats.entries_processed,
778 rewards_processed: stats.rewards_processed,
779 total_slots: total_slot_count,
780 elapsed_secs: elapsed_since_start as f64 / 1e9,
781 });
782 let slots_display = human_readable_count(processed_slots);
783 let blocks_display = human_readable_count(stats.blocks_processed);
784 let txs_display = human_readable_count(stats.transactions_processed);
785 let tps_display = human_readable_count(tps.ceil() as u64);
786 log::info!(
787 target: &log_target,
788 "{overall_progress:.1}% | ETA: {} | {tps_display} TPS | {slots_display} slots | {blocks_display} blocks | {txs_display} txs | thread: {thread_progress:.1}%",
789 overall_eta.unwrap_or_else(|| "n/a".into()),
790 );
791 Ok(())
792 }
793 .boxed()
794 }
795 },
796 tracking_interval_slots: 100,
797 }
798 });
799
800 let (shutdown_tx, _) = broadcast::channel::<()>(1);
801
802 let mut firehose_future = Box::pin(firehose(
803 self.num_threads as u64,
804 self.sequential,
805 self.reverse,
806 self.buffer_window_bytes,
807 slot_range,
808 Some(on_block),
809 Some(on_transaction),
810 Some(on_entry),
811 Some(on_reward),
812 Some(on_error),
813 stats_tracking,
814 Some(shutdown_tx.subscribe()),
815 ));
816
817 let firehose_result = tokio::select! {
818 res = &mut firehose_future => res,
819 ctrl = signal::ctrl_c() => {
820 match ctrl {
821 Ok(()) => log::info!(
822 target: LOG_MODULE,
823 "CTRL+C received; initiating shutdown"
824 ),
825 Err(err) => log::error!(
826 target: LOG_MODULE,
827 "failed to listen for CTRL+C: {}",
828 err
829 ),
830 }
831 shutting_down.store(true, Ordering::SeqCst);
832 let _ = shutdown_tx.send(());
833 firehose_future.await
834 }
835 };
836
837 drain_outstanding_writes().await;
841
842 if clickhouse_enabled && let Some(db_client) = clickhouse.clone() {
843 flush_slot_buffer(db_client, slot_buffer.clone()).await;
844 }
845
846 for handle in plugin_handles.iter() {
847 if let Err(error) = handle
848 .plugin
849 .on_exit(clickhouse.clone())
850 .await
851 .map_err(|e| e.to_string())
852 {
853 log::error!(
854 target: LOG_MODULE,
855 "plugin {} on_exit error: {}",
856 handle.name,
857 error
858 );
859 }
860 }
861
862 match firehose_result {
863 Ok(()) => Ok(()),
864 Err((error, slot)) => Err(PluginRunnerError::Firehose {
865 details: error.to_string(),
866 slot,
867 }),
868 }
869 }
870}
871
872fn build_clickhouse_client(dsn: &str) -> Client {
873 let mut client = Client::default();
874 if let Ok(mut url) = Url::parse(dsn) {
875 let username = url.username().to_string();
876 let password = url.password().map(|value| value.to_string());
877 if !username.is_empty() || password.is_some() {
878 let _ = url.set_username("");
879 let _ = url.set_password(None);
880 }
881 client = client.with_url(url.as_str());
882 if !username.is_empty() {
883 client = client.with_user(username);
884 }
885 if let Some(password) = password {
886 client = client.with_password(password);
887 }
888 } else {
889 client = client.with_url(dsn);
890 }
891 client
892}
893
894#[derive(Debug, Error)]
896pub enum PluginRunnerError {
897 #[error("clickhouse error: {0}")]
899 Clickhouse(#[from] clickhouse::error::Error),
900 #[error("firehose error at slot {slot}: {details}")]
902 Firehose {
903 details: String,
905 slot: u64,
907 },
908 #[error("plugin {plugin} failed during {stage}: {details}")]
910 PluginLifecycle {
911 plugin: &'static str,
913 stage: &'static str,
915 details: String,
917 },
918}
919
920#[derive(Clone)]
921struct PluginHandle {
922 plugin: Arc<dyn Plugin>,
923 id: u16,
924 name: &'static str,
925 version: u16,
926}
927
928impl From<Arc<dyn Plugin>> for PluginHandle {
929 fn from(plugin: Arc<dyn Plugin>) -> Self {
930 let id = plugin.id();
931 let name = plugin.name();
932 let version = plugin.version();
933 Self {
934 plugin,
935 id,
936 name,
937 version,
938 }
939 }
940}
941
942#[derive(Row, Serialize)]
943struct PluginRow<'a> {
944 id: u32,
945 name: &'a str,
946 version: u32,
947}
948
949#[derive(Row, Serialize, Clone)]
950struct PluginSlotRow {
951 plugin_id: u32,
952 slot: u64,
953}
954
955#[derive(Row, Serialize)]
956struct SlotStatusRow {
957 slot: u64,
958 transaction_count: u32,
959 vote_transaction_count: u32,
960 non_vote_transaction_count: u32,
961 thread_id: u8,
962 block_time: u32,
963}
964
965#[derive(Default, Clone, Copy)]
966struct SlotTxTally {
967 votes: u64,
968 non_votes: u64,
969}
970
971static SLOT_TX_TALLY: Lazy<DashMap<u64, SlotTxTally, ahash::RandomState>> =
972 Lazy::new(|| DashMap::with_hasher(ahash::RandomState::new()));
973
974async fn ensure_clickhouse_tables(db: &Client) -> Result<(), clickhouse::error::Error> {
975 db.query(
976 r#"CREATE TABLE IF NOT EXISTS jetstreamer_slot_status (
977 slot UInt64,
978 transaction_count UInt32 DEFAULT 0,
979 vote_transaction_count UInt32 DEFAULT 0,
980 non_vote_transaction_count UInt32 DEFAULT 0,
981 thread_id UInt8 DEFAULT 0,
982 block_time DateTime('UTC') DEFAULT toDateTime(0),
983 indexed_at DateTime('UTC') DEFAULT now()
984 ) ENGINE = ReplacingMergeTree(indexed_at)
985 ORDER BY slot"#,
986 )
987 .execute()
988 .await?;
989
990 db.query(
991 r#"CREATE TABLE IF NOT EXISTS jetstreamer_plugins (
992 id UInt32,
993 name String,
994 version UInt32
995 ) ENGINE = ReplacingMergeTree
996 ORDER BY id"#,
997 )
998 .execute()
999 .await?;
1000
1001 db.query(
1002 r#"CREATE TABLE IF NOT EXISTS jetstreamer_plugin_slots (
1003 plugin_id UInt32,
1004 slot UInt64,
1005 indexed_at DateTime('UTC') DEFAULT now()
1006 ) ENGINE = ReplacingMergeTree
1007 ORDER BY (plugin_id, slot)"#,
1008 )
1009 .execute()
1010 .await?;
1011
1012 Ok(())
1013}
1014
1015async fn upsert_plugins(
1016 db: &Client,
1017 plugins: &[PluginHandle],
1018) -> Result<(), clickhouse::error::Error> {
1019 if plugins.is_empty() {
1020 return Ok(());
1021 }
1022 let mut insert = db.insert::<PluginRow>("jetstreamer_plugins").await?;
1023 for handle in plugins {
1024 insert
1025 .write(&PluginRow {
1026 id: handle.id as u32,
1027 name: handle.name,
1028 version: handle.version as u32,
1029 })
1030 .await?;
1031 }
1032 insert.end().await?;
1033 Ok(())
1034}
1035
1036async fn record_plugin_slot(
1037 db: Arc<Client>,
1038 plugin_id: u16,
1039 slot: u64,
1040) -> Result<(), clickhouse::error::Error> {
1041 let mut insert = db
1042 .insert::<PluginSlotRow>("jetstreamer_plugin_slots")
1043 .await?;
1044 insert
1045 .write(&PluginSlotRow {
1046 plugin_id: plugin_id as u32,
1047 slot,
1048 })
1049 .await?;
1050 insert.end().await?;
1051 Ok(())
1052}
1053
1054async fn flush_slot_buffer(
1058 db: Arc<Client>,
1059 buffer: Arc<DashMap<u16, Vec<PluginSlotRow>, ahash::RandomState>>,
1060) {
1061 let mut rows = Vec::new();
1062 buffer.iter_mut().for_each(|mut entry| {
1063 if !entry.value().is_empty() {
1064 rows.append(entry.value_mut());
1065 }
1066 });
1067
1068 if rows.is_empty() {
1069 return;
1070 }
1071
1072 retry_clickhouse_write("plugin slot flush", || {
1073 let db = Arc::clone(&db);
1074 let rows = rows.clone();
1075 async move {
1076 let mut insert = db
1077 .insert::<PluginSlotRow>("jetstreamer_plugin_slots")
1078 .await?;
1079 for row in &rows {
1080 insert.write(row).await?;
1081 }
1082 insert.end().await?;
1083 Ok(())
1084 }
1085 })
1086 .await;
1087}
1088
1089static WRITES_IN_FLIGHT: AtomicU64 = AtomicU64::new(0);
1092
1093struct InFlightWrite;
1096
1097impl InFlightWrite {
1098 fn begin() -> Self {
1099 WRITES_IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
1100 Self
1101 }
1102}
1103
1104impl Drop for InFlightWrite {
1105 fn drop(&mut self) {
1106 WRITES_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
1107 }
1108}
1109
1110pub(crate) fn spawn_tracked_write<F>(write: F)
1115where
1116 F: Future<Output = ()> + Send + 'static,
1117{
1118 let guard = InFlightWrite::begin();
1119 tokio::spawn(async move {
1120 let _guard = guard;
1121 write.await;
1122 });
1123}
1124
1125async fn drain_outstanding_writes() {
1131 let mut last_logged = std::time::Instant::now();
1132 let mut logged = false;
1133 loop {
1134 let in_flight = WRITES_IN_FLIGHT.load(Ordering::SeqCst);
1135 if in_flight == 0 {
1136 if logged {
1137 log::info!(target: LOG_MODULE, "all outstanding clickhouse writes finished");
1138 }
1139 return;
1140 }
1141 if !logged || last_logged.elapsed() >= Duration::from_secs(5) {
1142 log::info!(
1143 target: LOG_MODULE,
1144 "waiting for {in_flight} outstanding clickhouse write task(s) to finish before shutdown..."
1145 );
1146 last_logged = std::time::Instant::now();
1147 logged = true;
1148 }
1149 tokio::time::sleep(Duration::from_millis(50)).await;
1150 }
1151}
1152
1153pub(crate) async fn retry_clickhouse_write<F, Fut>(what: &'static str, mut write: F)
1162where
1163 F: FnMut() -> Fut,
1164 Fut: std::future::Future<Output = Result<(), clickhouse::error::Error>>,
1165{
1166 const RETRY_HORIZON: Duration = Duration::from_secs(600);
1167 let started = std::time::Instant::now();
1168 let mut delay = Duration::from_millis(500);
1169 let mut attempt: u32 = 1;
1170 loop {
1171 match write().await {
1172 Ok(()) => {
1173 if attempt > 1 {
1174 log::info!("clickhouse write '{what}' succeeded on attempt {attempt}");
1175 }
1176 return;
1177 }
1178 Err(err) => {
1179 if started.elapsed() >= RETRY_HORIZON {
1180 let resume_hint = match (
1181 jetstreamer_firehose::firehose::resume_floor(),
1182 metrics::run_slot_range(),
1183 ) {
1184 (Some(floor), Some((_, end))) => {
1185 let range = format!("{floor}:{}", end.saturating_sub(1));
1186 let command = metrics::resume_command_template()
1187 .map(|template| template.replace("{range}", &range))
1188 .unwrap_or_else(|| format!("jetstreamer {range} <your original flags>"));
1189 format!(
1190 "everything below slot {floor} is fully processed; resume with: {command} (overlapping rows deduplicate via ReplacingMergeTree)"
1191 )
1192 }
1193 _ => "re-run the same range to resume (overlapping rows deduplicate via ReplacingMergeTree)".to_string(),
1194 };
1195 log::error!(
1198 "FATAL: clickhouse write '{what}' still failing after {:?} ({attempt} attempts); aborting run to avoid silent data loss: {err}. {resume_hint}",
1199 started.elapsed()
1200 );
1201 eprintln!(
1202 "FATAL: clickhouse write '{what}' still failing after {:?} ({attempt} attempts); aborting run to avoid silent data loss: {err}. {resume_hint}",
1203 started.elapsed()
1204 );
1205 std::process::exit(1);
1206 }
1207 metrics::note_db_retry();
1208 log::warn!(
1209 "clickhouse write '{what}' failed (attempt {attempt}); retrying in {delay:?}: {err}"
1210 );
1211 tokio::time::sleep(delay).await;
1212 delay = (delay * 2).min(Duration::from_secs(15));
1213 attempt += 1;
1214 }
1215 }
1216 }
1217}
1218
1219async fn record_slot_status(
1220 db: Arc<Client>,
1221 slot: u64,
1222 thread_id: usize,
1223 transaction_count: u64,
1224 vote_transaction_count: u64,
1225 non_vote_transaction_count: u64,
1226 block_time: Option<i64>,
1227) -> Result<(), clickhouse::error::Error> {
1228 let mut insert = db
1229 .insert::<SlotStatusRow>("jetstreamer_slot_status")
1230 .await?;
1231 insert
1232 .write(&SlotStatusRow {
1233 slot,
1234 transaction_count: transaction_count.min(u32::MAX as u64) as u32,
1235 vote_transaction_count: vote_transaction_count.min(u32::MAX as u64) as u32,
1236 non_vote_transaction_count: non_vote_transaction_count.min(u32::MAX as u64) as u32,
1237 thread_id: thread_id.try_into().unwrap_or(u8::MAX),
1238 block_time: clamp_block_time(block_time),
1239 })
1240 .await?;
1241 insert.end().await?;
1242 Ok(())
1243}
1244
1245fn clamp_block_time(block_time: Option<i64>) -> u32 {
1246 match block_time {
1247 Some(ts) if ts > 0 && ts <= u32::MAX as i64 => ts as u32,
1248 Some(ts) if ts > u32::MAX as i64 => u32::MAX,
1249 Some(ts) if ts < 0 => 0,
1250 _ => 0,
1251 }
1252}
1253
1254fn record_slot_vote_tally(slot: u64, is_vote: bool) {
1255 let mut entry = SLOT_TX_TALLY.entry(slot).or_default();
1256 if is_vote {
1257 entry.votes = entry.votes.saturating_add(1);
1258 } else {
1259 entry.non_votes = entry.non_votes.saturating_add(1);
1260 }
1261}
1262
1263fn take_slot_tx_tally(slot: u64) -> SlotTxTally {
1264 SLOT_TX_TALLY
1265 .remove(&slot)
1266 .map(|(_, tally)| tally)
1267 .unwrap_or_default()
1268}
1269
1270trait _CanSend: Send + Sync + 'static {}
1272impl _CanSend for PluginRunnerError {}
1273
1274#[inline]
1275fn human_readable_count(value: impl Into<u128>) -> String {
1276 let digits = value.into().to_string();
1277 let len = digits.len();
1278 let mut formatted = String::with_capacity(len + len / 3);
1279 for (idx, byte) in digits.bytes().enumerate() {
1280 if idx != 0 && (len - idx) % 3 == 0 {
1281 formatted.push(',');
1282 }
1283 formatted.push(char::from(byte));
1284 }
1285 formatted
1286}
1287
1288fn human_readable_duration(seconds: f64) -> String {
1289 if !seconds.is_finite() {
1290 return "n/a".into();
1291 }
1292 if seconds <= 0.0 {
1293 return "0s".into();
1294 }
1295 if seconds < 60.0 {
1296 return format!("{:.1}s", seconds);
1297 }
1298 let duration = Duration::from_secs(seconds.round() as u64);
1299 let secs = duration.as_secs();
1300 let days = secs / 86_400;
1301 let hours = (secs % 86_400) / 3_600;
1302 let minutes = (secs % 3_600) / 60;
1303 let seconds_rem = secs % 60;
1304 if days > 0 {
1305 if hours > 0 {
1306 format!("{}d{}h", days, hours)
1307 } else {
1308 format!("{}d", days)
1309 }
1310 } else if hours > 0 {
1311 format!("{}h{}m", hours, minutes)
1312 } else {
1313 format!("{}m{}s", minutes, seconds_rem)
1314 }
1315}