Skip to main content

jetstreamer_plugin/plugins/
pubkey_stats.rs

1use std::sync::Arc;
2
3use clickhouse::{Client, Row};
4use dashmap::DashMap;
5use futures_util::FutureExt;
6use once_cell::sync::Lazy;
7use serde::{Deserialize, Serialize};
8use solana_address::Address;
9use solana_message::VersionedMessage;
10
11use crate::{Plugin, PluginFuture};
12use jetstreamer_firehose::firehose::{BlockData, TransactionData};
13
14/// Per-slot accumulator: maps each pubkey to its mention count within that slot.
15static PENDING_BY_SLOT: Lazy<
16    DashMap<u64, DashMap<Address, u32, ahash::RandomState>, ahash::RandomState>,
17> = Lazy::new(|| DashMap::with_hasher(ahash::RandomState::new()));
18
19#[derive(Row, Deserialize, Serialize, Copy, Clone, Debug)]
20struct PubkeyMention {
21    slot: u32,
22    timestamp: u32,
23    pubkey: Address,
24    num_mentions: u32,
25}
26
27#[derive(Debug, Clone)]
28/// Tracks per-slot pubkey mention counts and writes them to ClickHouse.
29///
30/// For every transaction, all account keys referenced in the message (both static and loaded)
31/// are counted. A ClickHouse `pubkey_mentions` table stores the aggregated count per
32/// `(slot, pubkey)` pair using `ReplacingMergeTree` for safe parallel ingestion.
33///
34/// A companion `pubkeys` table assigns a unique auto-incremented id to each pubkey, maintained
35/// via a materialised view so lookups by id are efficient.
36pub struct PubkeyStatsPlugin;
37
38impl PubkeyStatsPlugin {
39    /// Creates a new instance.
40    pub const fn new() -> Self {
41        Self
42    }
43
44    fn take_slot_events(slot: u64, block_time: Option<i64>) -> Vec<PubkeyMention> {
45        let timestamp = clamp_block_time(block_time);
46        if let Some((_, pubkey_counts)) = PENDING_BY_SLOT.remove(&slot) {
47            return pubkey_counts
48                .into_iter()
49                .map(|(pubkey, num_mentions)| PubkeyMention {
50                    slot: slot.min(u32::MAX as u64) as u32,
51                    timestamp,
52                    pubkey,
53                    num_mentions,
54                })
55                .collect();
56        }
57        Vec::new()
58    }
59
60    fn drain_all_pending(block_time: Option<i64>) -> Vec<PubkeyMention> {
61        let timestamp = clamp_block_time(block_time);
62        let slots: Vec<u64> = PENDING_BY_SLOT.iter().map(|entry| *entry.key()).collect();
63        let mut rows = Vec::new();
64        for slot in slots {
65            if let Some((_, pubkey_counts)) = PENDING_BY_SLOT.remove(&slot) {
66                rows.extend(pubkey_counts.into_iter().map(|(pubkey, num_mentions)| {
67                    PubkeyMention {
68                        slot: slot.min(u32::MAX as u64) as u32,
69                        timestamp,
70                        pubkey,
71                        num_mentions,
72                    }
73                }));
74            }
75        }
76        rows
77    }
78}
79
80impl Default for PubkeyStatsPlugin {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl Plugin for PubkeyStatsPlugin {
87    #[inline(always)]
88    fn name(&self) -> &'static str {
89        "Pubkey Stats"
90    }
91
92    #[inline(always)]
93    fn on_transaction<'a>(
94        &'a self,
95        _thread_id: usize,
96        _db: Option<Arc<Client>>,
97        transaction: &'a TransactionData,
98    ) -> PluginFuture<'a> {
99        async move {
100            let account_keys = match &transaction.transaction.message {
101                VersionedMessage::Legacy(msg) => &msg.account_keys,
102                VersionedMessage::V0(msg) => &msg.account_keys,
103            };
104            if account_keys.is_empty() {
105                return Ok(());
106            }
107
108            let slot = transaction.slot;
109            let slot_entry = PENDING_BY_SLOT
110                .entry(slot)
111                .or_insert_with(|| DashMap::with_hasher(ahash::RandomState::new()));
112            for pubkey in account_keys {
113                *slot_entry.entry(*pubkey).or_insert(0) += 1;
114            }
115
116            Ok(())
117        }
118        .boxed()
119    }
120
121    #[inline(always)]
122    fn on_block(
123        &self,
124        _thread_id: usize,
125        db: Option<Arc<Client>>,
126        block: &BlockData,
127    ) -> PluginFuture<'_> {
128        let slot = block.slot();
129        let block_time = block.block_time();
130        let was_skipped = block.was_skipped();
131        async move {
132            if was_skipped {
133                return Ok(());
134            }
135
136            let rows = Self::take_slot_events(slot, block_time);
137
138            if let Some(db_client) = db
139                && !rows.is_empty()
140            {
141                crate::spawn_tracked_write(async move {
142                    crate::retry_clickhouse_write("pubkey mentions", || {
143                        write_pubkey_mentions(Arc::clone(&db_client), rows.clone())
144                    })
145                    .await;
146                });
147            }
148
149            Ok(())
150        }
151        .boxed()
152    }
153
154    #[inline(always)]
155    fn on_load(&self, db: Option<Arc<Client>>) -> PluginFuture<'_> {
156        async move {
157            log::info!("Pubkey Stats Plugin loaded.");
158            if let Some(db) = db {
159                log::info!("Creating pubkey_mentions table if it does not exist...");
160                db.query(
161                    r#"
162                    CREATE TABLE IF NOT EXISTS pubkey_mentions (
163                        slot          UInt32,
164                        timestamp     DateTime('UTC'),
165                        pubkey        FixedString(32),
166                        num_mentions  UInt32
167                    )
168                    ENGINE = ReplacingMergeTree(timestamp)
169                    ORDER BY (slot, pubkey)
170                    "#,
171                )
172                .execute()
173                .await?;
174
175                log::info!("Creating pubkeys table if it does not exist...");
176                db.query(
177                    r#"
178                    CREATE TABLE IF NOT EXISTS pubkeys (
179                        pubkey  FixedString(32),
180                        id      UInt64
181                    )
182                    ENGINE = ReplacingMergeTree()
183                    ORDER BY pubkey
184                    "#,
185                )
186                .execute()
187                .await?;
188
189                log::info!("Creating pubkeys materialised view if it does not exist...");
190                db.query(
191                    r#"
192                    CREATE MATERIALIZED VIEW IF NOT EXISTS pubkeys_mv TO pubkeys AS
193                    SELECT
194                        pubkey,
195                        sipHash64(pubkey) AS id
196                    FROM pubkey_mentions
197                    GROUP BY pubkey
198                    "#,
199                )
200                .execute()
201                .await?;
202
203                log::info!("done.");
204            } else {
205                log::warn!(
206                    "Pubkey Stats Plugin running without ClickHouse; data will not be persisted."
207                );
208            }
209            Ok(())
210        }
211        .boxed()
212    }
213
214    #[inline(always)]
215    fn on_exit(&self, db: Option<Arc<Client>>) -> PluginFuture<'_> {
216        async move {
217            if let Some(db_client) = db {
218                let rows = Self::drain_all_pending(None);
219                if !rows.is_empty() {
220                    crate::retry_clickhouse_write("pubkey mentions (exit flush)", || {
221                        write_pubkey_mentions(Arc::clone(&db_client), rows.clone())
222                    })
223                    .await;
224                }
225                crate::retry_clickhouse_write("pubkey timestamp backfill", || {
226                    backfill_pubkey_timestamps(Arc::clone(&db_client))
227                })
228                .await;
229            }
230            Ok(())
231        }
232        .boxed()
233    }
234}
235
236async fn write_pubkey_mentions(
237    db: Arc<Client>,
238    rows: Vec<PubkeyMention>,
239) -> Result<(), clickhouse::error::Error> {
240    if rows.is_empty() {
241        return Ok(());
242    }
243    let mut insert = db.insert::<PubkeyMention>("pubkey_mentions").await?;
244    for row in rows {
245        insert.write(&row).await?;
246    }
247    insert.end().await?;
248    Ok(())
249}
250
251fn clamp_block_time(block_time: Option<i64>) -> u32 {
252    let Some(raw_ts) = block_time else {
253        return 0;
254    };
255    if raw_ts < 0 {
256        0
257    } else if raw_ts > u32::MAX as i64 {
258        u32::MAX
259    } else {
260        raw_ts as u32
261    }
262}
263
264async fn backfill_pubkey_timestamps(db: Arc<Client>) -> Result<(), clickhouse::error::Error> {
265    db.query(
266        r#"
267        INSERT INTO pubkey_mentions
268        SELECT pm.slot,
269               ss.block_time,
270               pm.pubkey,
271               pm.num_mentions
272        FROM pubkey_mentions AS pm
273        ANY INNER JOIN jetstreamer_slot_status AS ss USING (slot)
274        WHERE pm.timestamp = toDateTime(0)
275          AND ss.block_time > toDateTime(0)
276        "#,
277    )
278    .execute()
279    .await?;
280
281    Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::Plugin;
288    use jetstreamer_firehose::firehose::{BlockData, TransactionData};
289    use serial_test::serial;
290    use solana_hash::Hash;
291    use solana_message::VersionedMessage;
292    use solana_message::legacy::Message as LegacyMessage;
293    use solana_runtime::bank::KeyedRewardsAndNumPartitions;
294    use solana_transaction::versioned::VersionedTransaction;
295    use solana_transaction_status::TransactionStatusMeta;
296
297    fn make_tx(slot: u64, account_keys: Vec<Address>) -> TransactionData {
298        let message = LegacyMessage {
299            account_keys,
300            ..LegacyMessage::default()
301        };
302        TransactionData {
303            slot,
304            transaction_slot_index: 0,
305            signature: Default::default(),
306            message_hash: Hash::default(),
307            is_vote: false,
308            transaction_status_meta: TransactionStatusMeta {
309                status: Ok(()),
310                fee: 0,
311                pre_balances: vec![],
312                post_balances: vec![],
313                inner_instructions: None,
314                log_messages: None,
315                pre_token_balances: None,
316                post_token_balances: None,
317                rewards: None,
318                loaded_addresses: Default::default(),
319                return_data: None,
320                compute_units_consumed: Some(0),
321                cost_units: None,
322            },
323            transaction: VersionedTransaction {
324                signatures: vec![],
325                message: VersionedMessage::Legacy(message),
326            },
327        }
328    }
329
330    fn make_block(slot: u64, block_time: Option<i64>) -> BlockData {
331        BlockData::Block {
332            slot,
333            parent_slot: slot.saturating_sub(1),
334            blockhash: Hash::default(),
335            parent_blockhash: Hash::default(),
336            rewards: KeyedRewardsAndNumPartitions {
337                keyed_rewards: vec![],
338                num_partitions: None,
339            },
340            block_time,
341            block_height: Some(slot),
342            executed_transaction_count: 0,
343            entry_count: 0,
344        }
345    }
346
347    fn clear_pending() {
348        PENDING_BY_SLOT.clear();
349    }
350
351    #[test]
352    fn clamp_block_time_none_returns_zero() {
353        assert_eq!(clamp_block_time(None), 0);
354    }
355
356    #[test]
357    fn clamp_block_time_negative_returns_zero() {
358        assert_eq!(clamp_block_time(Some(-100)), 0);
359    }
360
361    #[test]
362    fn clamp_block_time_overflow_returns_max() {
363        assert_eq!(clamp_block_time(Some(u32::MAX as i64 + 1)), u32::MAX);
364    }
365
366    #[test]
367    fn clamp_block_time_normal() {
368        assert_eq!(clamp_block_time(Some(1_700_000_000)), 1_700_000_000);
369    }
370
371    #[serial]
372    #[tokio::test]
373    async fn single_transaction_counts_all_account_keys() {
374        clear_pending();
375        let plugin = PubkeyStatsPlugin::new();
376        let key_a = Address::from([1u8; 32]);
377        let key_b = Address::from([2u8; 32]);
378        let key_c = Address::from([3u8; 32]);
379        let tx = make_tx(100, vec![key_a, key_b, key_c]);
380
381        plugin.on_transaction(0, None, &tx).await.unwrap();
382
383        let events = PubkeyStatsPlugin::take_slot_events(100, Some(1_700_000_000));
384        assert_eq!(events.len(), 3);
385        for event in &events {
386            assert_eq!(event.num_mentions, 1);
387            assert_eq!(event.slot, 100);
388            assert_eq!(event.timestamp, 1_700_000_000);
389        }
390    }
391
392    #[serial]
393    #[tokio::test]
394    async fn duplicate_keys_in_single_transaction_accumulate() {
395        clear_pending();
396        let plugin = PubkeyStatsPlugin::new();
397        let key_a = Address::from([1u8; 32]);
398        let tx = make_tx(200, vec![key_a, key_a, key_a]);
399
400        plugin.on_transaction(0, None, &tx).await.unwrap();
401
402        let events = PubkeyStatsPlugin::take_slot_events(200, None);
403        assert_eq!(events.len(), 1);
404        assert_eq!(events[0].num_mentions, 3);
405        assert_eq!(events[0].pubkey, key_a);
406    }
407
408    #[serial]
409    #[tokio::test]
410    async fn multiple_transactions_same_slot_accumulate() {
411        clear_pending();
412        let plugin = PubkeyStatsPlugin::new();
413        let key_a = Address::from([10u8; 32]);
414        let key_b = Address::from([20u8; 32]);
415
416        let tx1 = make_tx(300, vec![key_a, key_b]);
417        let tx2 = make_tx(300, vec![key_a]);
418
419        plugin.on_transaction(0, None, &tx1).await.unwrap();
420        plugin.on_transaction(0, None, &tx2).await.unwrap();
421
422        let events = PubkeyStatsPlugin::take_slot_events(300, None);
423        assert_eq!(events.len(), 2);
424        let a_event = events.iter().find(|e| e.pubkey == key_a).unwrap();
425        let b_event = events.iter().find(|e| e.pubkey == key_b).unwrap();
426        assert_eq!(a_event.num_mentions, 2);
427        assert_eq!(b_event.num_mentions, 1);
428    }
429
430    #[serial]
431    #[tokio::test]
432    async fn different_slots_are_independent() {
433        clear_pending();
434        let plugin = PubkeyStatsPlugin::new();
435        let key = Address::from([42u8; 32]);
436
437        let tx1 = make_tx(400, vec![key]);
438        let tx2 = make_tx(401, vec![key]);
439
440        plugin.on_transaction(0, None, &tx1).await.unwrap();
441        plugin.on_transaction(0, None, &tx2).await.unwrap();
442
443        let events_400 = PubkeyStatsPlugin::take_slot_events(400, None);
444        let events_401 = PubkeyStatsPlugin::take_slot_events(401, None);
445        assert_eq!(events_400.len(), 1);
446        assert_eq!(events_401.len(), 1);
447        assert_eq!(events_400[0].num_mentions, 1);
448        assert_eq!(events_401[0].num_mentions, 1);
449    }
450
451    #[serial]
452    #[tokio::test]
453    async fn take_slot_events_drains_slot() {
454        clear_pending();
455        let plugin = PubkeyStatsPlugin::new();
456        let tx = make_tx(500, vec![Address::from([1u8; 32])]);
457        plugin.on_transaction(0, None, &tx).await.unwrap();
458
459        let first = PubkeyStatsPlugin::take_slot_events(500, None);
460        let second = PubkeyStatsPlugin::take_slot_events(500, None);
461        assert_eq!(first.len(), 1);
462        assert!(second.is_empty());
463    }
464
465    #[serial]
466    #[tokio::test]
467    async fn drain_all_pending_collects_all_slots() {
468        clear_pending();
469        let plugin = PubkeyStatsPlugin::new();
470
471        let tx1 = make_tx(600, vec![Address::from([1u8; 32])]);
472        let tx2 = make_tx(601, vec![Address::from([2u8; 32])]);
473        let tx3 = make_tx(602, vec![Address::from([3u8; 32])]);
474
475        plugin.on_transaction(0, None, &tx1).await.unwrap();
476        plugin.on_transaction(0, None, &tx2).await.unwrap();
477        plugin.on_transaction(0, None, &tx3).await.unwrap();
478
479        let events = PubkeyStatsPlugin::drain_all_pending(Some(1_000));
480        assert_eq!(events.len(), 3);
481        assert!(PENDING_BY_SLOT.is_empty());
482    }
483
484    #[serial]
485    #[tokio::test]
486    async fn empty_account_keys_produces_no_events() {
487        clear_pending();
488        let plugin = PubkeyStatsPlugin::new();
489        let tx = make_tx(700, vec![]);
490        plugin.on_transaction(0, None, &tx).await.unwrap();
491        assert!(PENDING_BY_SLOT.is_empty());
492    }
493
494    #[serial]
495    #[tokio::test]
496    async fn on_block_drains_pending_slot() {
497        clear_pending();
498        let plugin = PubkeyStatsPlugin::new();
499        let tx = make_tx(800, vec![Address::from([1u8; 32])]);
500        plugin.on_transaction(0, None, &tx).await.unwrap();
501        assert!(!PENDING_BY_SLOT.is_empty());
502
503        let block = make_block(800, Some(1_700_000_000));
504        plugin.on_block(0, None, &block).await.unwrap();
505
506        // on_block without db just drains, doesn't write
507        assert!(!PENDING_BY_SLOT.contains_key(&800));
508    }
509
510    #[serial]
511    #[tokio::test]
512    async fn skipped_block_does_not_drain() {
513        clear_pending();
514        let plugin = PubkeyStatsPlugin::new();
515        let tx = make_tx(900, vec![Address::from([1u8; 32])]);
516        plugin.on_transaction(0, None, &tx).await.unwrap();
517
518        let skipped = BlockData::PossibleLeaderSkipped { slot: 900 };
519        plugin.on_block(0, None, &skipped).await.unwrap();
520
521        assert!(PENDING_BY_SLOT.contains_key(&900));
522        clear_pending();
523    }
524
525    #[test]
526    fn plugin_name() {
527        assert_eq!(PubkeyStatsPlugin::new().name(), "Pubkey Stats");
528    }
529
530    #[serial]
531    #[test]
532    fn slot_clamped_to_u32_max() {
533        let slot = u64::from(u32::MAX) + 100;
534        PENDING_BY_SLOT.clear();
535        let inner = DashMap::with_hasher(ahash::RandomState::new());
536        inner.insert(Address::from([1u8; 32]), 5);
537        PENDING_BY_SLOT.insert(slot, inner);
538
539        let events = PubkeyStatsPlugin::take_slot_events(slot, None);
540        assert_eq!(events.len(), 1);
541        assert_eq!(events[0].slot, u32::MAX);
542    }
543}