Skip to main content

jetstreamer_plugin/
lib.rs

1#![deny(missing_docs)]
2//! Trait-based framework for building structured observers on top of Jetstreamer's firehose.
3//!
4//! # Overview
5//! Plugins let you react to every block, transaction, reward, entry, and stats update emitted
6//! by [`jetstreamer_firehose`](https://crates.io/crates/jetstreamer-firehose). Combined with
7//! the
8//! [`JetstreamerRunner`](https://docs.rs/jetstreamer/latest/jetstreamer/struct.JetstreamerRunner.html),
9//! they provide a high-throughput analytics pipeline capable of exceeding 2.7 million
10//! transactions per second on the right hardware. All events originate from Old Faithful's CAR
11//! archive and are streamed over the network into your local runner.
12//!
13//! The framework offers:
14//! - A [`Plugin`] trait with async hook points for each data type.
15//! - [`PluginRunner`] for coordinating multiple plugins with shared ClickHouse connections
16//!   (used internally by `JetstreamerRunner`).
17//! - Built-in plugins under [`plugins`] that demonstrate common batching strategies and
18//!   metrics.
19//! - See `JetstreamerRunner` in the `jetstreamer` crate for the easiest way to run plugins.
20//!
21//! # ClickHouse Integration
22//! Jetstreamer plugins are typically paired with ClickHouse for persistence. Runner instances
23//! honor the following environment variables:
24//! - `JETSTREAMER_CLICKHOUSE_DSN` (default `http://localhost:8123`): HTTP(S) DSN handed to
25//!   every plugin that requests a database handle.
26//! - `JETSTREAMER_CLICKHOUSE_MODE` (default `auto`): toggles the bundled ClickHouse helper.
27//!   Set to `remote` to opt out of spawning the helper while still writing to a cluster,
28//!   `local` to always spawn, or `off` to disable ClickHouse entirely.
29//!
30//! When the mode is `auto`, Jetstreamer inspects the DSN at runtime and only launches the
31//! embedded helper for local endpoints, enabling native clustering workflows out of the box.
32//!
33//! ## Write Durability
34//! Writes issued by the runner and the bundled plugins are never silently dropped. Inserts
35//! use `async_insert` with `wait_for_async_insert=1` (an acknowledgment means durably
36//! flushed), failures are retried with exponential backoff for up to 10 minutes, in-flight
37//! write tasks are tracked and drained at shutdown (so runtime teardown never cancels a
38//! batch mid-delivery), and a write that is still failing after the horizon aborts the run
39//! with a message that includes the exact command to resume from the lowest unprocessed
40//! slot. Retries provide at-least-once
41//! delivery: every bundled table is a `ReplacingMergeTree` keyed on its logical identity, so
42//! replayed batches deduplicate on merge — query with `FINAL` (or tolerate transient
43//! duplicates) when reading while ingestion is active.
44//!
45//! # Batching ClickHouse Writes
46//! ClickHouse (and any sinks you invoke inside hook handlers) can apply backpressure on large
47//! numbers of tiny inserts. Plugins should buffer work locally and flush in batches on a
48//! cadence that matches their workload. The default [`PluginRunner`] configuration triggers
49//! stats pulses every 100 slots, which offers a reasonable heartbeat without thrashing the
50//! database. The bundled [`plugins::program_tracking::ProgramTrackingPlugin`] mirrors this
51//! approach by accumulating `ProgramEvent` rows per worker thread and issuing a single batch
52//! insert every 1,000 slots. Adopting a similar strategy keeps long-running replays responsive
53//! even under peak throughput.
54//!
55//! # Ordering Guarantees
56//! Also note that because Jetstreamer spawns parallel threads that process different subranges of
57//! the overall slot range at the same time, while each thread sees a purely sequential view of
58//! transactions, downstream services such as databases that consume this data will see writes in a
59//! fairly arbitrary order, so you should design your database tables and shared data structures
60//! accordingly.
61//!
62//! # Examples
63//! ## Defining a Plugin
64//! ```no_run
65//! use std::sync::Arc;
66//! use clickhouse::Client;
67//! use futures_util::FutureExt;
68//! use jetstreamer_firehose::firehose::TransactionData;
69//! use jetstreamer_plugin::{Plugin, PluginFuture};
70//!
71//! struct CountingPlugin;
72//!
73//! impl Plugin for CountingPlugin {
74//!     fn name(&self) -> &'static str { "counting" }
75//!
76//!     fn on_transaction<'a>(
77//!         &'a self,
78//!         _thread_id: usize,
79//!         _db: Option<Arc<Client>>,
80//!         transaction: &'a TransactionData,
81//!     ) -> PluginFuture<'a> {
82//!         async move {
83//!             println!("saw tx {} in slot {}", transaction.signature, transaction.slot);
84//!             Ok(())
85//!         }
86//!         .boxed()
87//!     }
88//! }
89//! # let _plugin = CountingPlugin;
90//! ```
91//!
92//! ## Running Plugins with `PluginRunner`
93//! ```no_run
94//! use std::sync::Arc;
95//! use jetstreamer_firehose::epochs;
96//! use jetstreamer_plugin::{Plugin, PluginRunner};
97//!
98//! struct LoggingPlugin;
99//!
100//! impl Plugin for LoggingPlugin {
101//!     fn name(&self) -> &'static str { "logging" }
102//! }
103//!
104//! #[tokio::main]
105//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
106//!     let mut runner = PluginRunner::new("http://localhost:8123", 1, false, false, None);
107//!     runner.register(Box::new(LoggingPlugin));
108//!     let runner = Arc::new(runner);
109//!
110//!     let (start, _) = epochs::epoch_to_slot_range(800);
111//!     let (_, end_inclusive) = epochs::epoch_to_slot_range(805);
112//!     runner
113//!         .clone()
114//!         .run(start..(end_inclusive + 1), false)
115//!         .await?;
116//!     Ok(())
117//! }
118//! ```
119
120/// Global runtime metrics shared with frontends such as the CLI `--tui` mode.
121pub mod metrics;
122/// Built-in plugin implementations that ship with Jetstreamer.
123pub 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
153/// Re-exported statistics types produced by [`firehose`].
154pub use jetstreamer_firehose::firehose::{
155    FirehoseErrorContext, Stats as FirehoseStats, ThreadStats,
156};
157
158// Global totals snapshot used to compute overall TPS/ETA between pulses.
159static 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
168/// Convenience alias for the boxed future returned by plugin hooks.
169pub 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
177/// Trait implemented by plugins that consume firehose events.
178///
179/// See the crate-level documentation for usage examples.
180pub trait Plugin: Send + Sync + 'static {
181    /// Human-friendly plugin name used in logs and persisted metadata.
182    fn name(&self) -> &'static str;
183
184    /// Semantic version for the plugin; defaults to `1`.
185    fn version(&self) -> u16 {
186        1
187    }
188
189    /// Deterministic identifier derived from [`Plugin::name`].
190    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    /// Called for every transaction seen by the firehose.
200    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    /// Called for every block observed by the firehose.
210    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    /// Called for every entry observed by the firehose when entry notifications are enabled.
220    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    /// Called for reward updates associated with processed blocks.
230    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    /// Called whenever a firehose thread encounters an error before restarting.
240    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    /// Invoked once before the firehose starts streaming events.
250    fn on_load(&self, _db: Option<Arc<Client>>) -> PluginFuture<'_> {
251        async move { Ok(()) }.boxed()
252    }
253
254    /// Invoked once after the firehose finishes or shuts down.
255    fn on_exit(&self, _db: Option<Arc<Client>>) -> PluginFuture<'_> {
256        async move { Ok(()) }.boxed()
257    }
258}
259
260/// Coordinates plugin execution and ClickHouse persistence.
261///
262/// See the crate-level documentation for usage examples.
263#[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    /// Creates a new runner that writes to `clickhouse_dsn` using `num_threads`.
277    ///
278    /// When `sequential` is `true`, firehose runs with one worker and `num_threads` is used as
279    /// ripget parallel download concurrency. When `reverse` is `true`, epochs in the slot range
280    /// are streamed from highest to lowest; this implies sequential mode and activates it
281    /// automatically if not already set.
282    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    /// Enables TUI support: stats pulses are always tracked (even without ClickHouse) so the
302    /// frontend has data to render.
303    pub fn set_tui(&mut self, tui: bool) {
304        self.tui = tui;
305    }
306
307    /// Registers an additional plugin.
308    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    /// Runs the firehose across the specified slot range, optionally writing to ClickHouse.
315    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                    // Wait for the async buffer to flush before acking: an ack then means
334                    // durably written, so every failure is visible to the retry layer and
335                    // nothing can be lost in a post-ack flush failure. Retries may still
336                    // double-commit on ambiguous timeouts; ReplacingMergeTree absorbs that.
337                    .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                                // Drop any tallies that may exist for skipped slots.
472                                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        // Reset global rate snapshot for a new run.
652        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        // Stats pulses drive both the log lines and the TUI, so track them whenever either
659        // consumer is active.
660        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                            // Serialize snapshot updates so every pulse measures deltas from the
690                            // previous pulse (regardless of which thread emitted it) using a
691                            // monotonic clock shared across threads.
692                            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 fire-and-forget writes (including any parked in retry backoff)
838        // before flushing final state; past this point runtime teardown and the embedded
839        // ClickHouse shutdown cannot cancel a delivery.
840        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/// Errors that can arise while running plugins against the firehose.
895#[derive(Debug, Error)]
896pub enum PluginRunnerError {
897    /// ClickHouse client returned an error.
898    #[error("clickhouse error: {0}")]
899    Clickhouse(#[from] clickhouse::error::Error),
900    /// Firehose streaming failed at the specified slot.
901    #[error("firehose error at slot {slot}: {details}")]
902    Firehose {
903        /// Human-readable description of the firehose failure.
904        details: String,
905        /// Slot where the firehose encountered the error.
906        slot: u64,
907    },
908    /// Lifecycle hook on a plugin returned an error.
909    #[error("plugin {plugin} failed during {stage}: {details}")]
910    PluginLifecycle {
911        /// Name of the plugin that failed.
912        plugin: &'static str,
913        /// Lifecycle stage where the failure occurred.
914        stage: &'static str,
915        /// Textual error details.
916        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
1054/// Drains the shared slot buffer once and writes the drained rows with full retry
1055/// protection. The drain happens exactly once up front — retries replay the *drained* rows,
1056/// never re-drain the (now empty) buffer, so a failed attempt cannot lose them.
1057async 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
1089/// Number of spawned ClickHouse write tasks still in flight. Drained at shutdown so runtime
1090/// teardown can never cancel a write mid-delivery (including retries parked in backoff).
1091static WRITES_IN_FLIGHT: AtomicU64 = AtomicU64::new(0);
1092
1093/// RAII guard for one in-flight write task; decrements on drop so even a panicking task
1094/// cannot leak the counter and wedge shutdown.
1095struct 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
1110/// Spawns a fire-and-forget ClickHouse write task tracked by the in-flight counter. The
1111/// counter is incremented *before* spawning (in the caller's context), so by the time the
1112/// firehose finishes and shutdown reaches [`drain_outstanding_writes`], every write spawned
1113/// from a handler is guaranteed to be counted.
1114pub(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
1125/// Waits until every tracked ClickHouse write task has completed. Called during shutdown
1126/// after ingestion stops and before the embedded ClickHouse helper is stopped and the tokio
1127/// runtime is dropped — otherwise in-flight batches would be silently cancelled. The wait is
1128/// bounded by the write tasks' own retry horizon: they either succeed or terminate the
1129/// process, so this cannot hang forever.
1130async 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
1153/// Retries a ClickHouse write with exponential backoff (0.5s doubling to a 15s cap) for up
1154/// to 10 minutes. Every Jetstreamer table is a `ReplacingMergeTree` keyed on its logical
1155/// identity, so replaying a whole batch is idempotent — a rare double-commit collapses on
1156/// merge.
1157///
1158/// If the write is still failing after the full horizon, the process is terminated: silently
1159/// dropping ClickHouse data is never acceptable, and a database that has been unreachable
1160/// for 10 minutes means the run's output would be incomplete no matter what we do next.
1161pub(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                    // Both sinks on purpose: the ring logger owns `log` in TUI mode, and
1196                    // stderr survives the process teardown.
1197                    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
1270// Ensure PluginRunnerError is Send + Sync + 'static
1271trait _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}