jetstreamer_plugin/plugins/
instruction_tracking.rs1use 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_message::VersionedMessage;
9use solana_sdk_ids::vote::id as vote_program_id;
10
11use crate::{Plugin, PluginFuture};
12use jetstreamer_firehose::firehose::{BlockData, TransactionData};
13
14static PENDING_BY_SLOT: Lazy<DashMap<u64, SlotInstructionEvent, ahash::RandomState>> =
15 Lazy::new(|| DashMap::with_hasher(ahash::RandomState::new()));
16
17#[derive(Row, Deserialize, Serialize, Copy, Clone, Debug)]
18struct SlotInstructionEvent {
19 slot: u32,
20 timestamp: u32,
22 vote_instruction_count: u64,
23 non_vote_instruction_count: u64,
24 vote_transaction_count: u32,
25 non_vote_transaction_count: u32,
26}
27
28#[derive(Debug, Clone)]
29pub struct InstructionTrackingPlugin;
31
32impl InstructionTrackingPlugin {
33 pub const fn new() -> Self {
35 Self
36 }
37
38 fn take_slot_event(slot: u64, block_time: Option<i64>) -> Option<SlotInstructionEvent> {
39 let timestamp = clamp_block_time(block_time);
40 PENDING_BY_SLOT.remove(&slot).map(|(_, mut event)| {
41 event.timestamp = timestamp;
42 event
43 })
44 }
45
46 fn drain_all_pending(block_time: Option<i64>) -> Vec<SlotInstructionEvent> {
47 let timestamp = clamp_block_time(block_time);
48 let slots: Vec<u64> = PENDING_BY_SLOT.iter().map(|entry| *entry.key()).collect();
49 let mut rows = Vec::new();
50 for slot in slots {
51 if let Some((_, mut event)) = PENDING_BY_SLOT.remove(&slot) {
52 event.timestamp = timestamp;
53 rows.push(event);
54 }
55 }
56 rows
57 }
58}
59
60impl Default for InstructionTrackingPlugin {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl Plugin for InstructionTrackingPlugin {
67 #[inline(always)]
68 fn name(&self) -> &'static str {
69 "Instruction Tracking"
70 }
71
72 #[inline(always)]
73 fn on_transaction<'a>(
74 &'a self,
75 _thread_id: usize,
76 _db: Option<Arc<Client>>,
77 transaction: &'a TransactionData,
78 ) -> PluginFuture<'a> {
79 async move {
80 let (vote_instruction_count, non_vote_instruction_count) =
81 instruction_vote_counts(transaction);
82
83 let slot = transaction.slot;
84 let mut entry = PENDING_BY_SLOT
85 .entry(slot)
86 .or_insert_with(|| SlotInstructionEvent {
87 slot: slot.min(u32::MAX as u64) as u32,
88 timestamp: 0,
89 vote_instruction_count: 0,
90 non_vote_instruction_count: 0,
91 vote_transaction_count: 0,
92 non_vote_transaction_count: 0,
93 });
94 entry.vote_instruction_count = entry
95 .vote_instruction_count
96 .saturating_add(vote_instruction_count);
97 entry.non_vote_instruction_count = entry
98 .non_vote_instruction_count
99 .saturating_add(non_vote_instruction_count);
100 if vote_instruction_count > 0 {
101 entry.vote_transaction_count = entry.vote_transaction_count.saturating_add(1);
102 } else {
103 entry.non_vote_transaction_count =
104 entry.non_vote_transaction_count.saturating_add(1);
105 }
106
107 Ok(())
108 }
109 .boxed()
110 }
111
112 #[inline(always)]
113 fn on_block(
114 &self,
115 _thread_id: usize,
116 db: Option<Arc<Client>>,
117 block: &BlockData,
118 ) -> PluginFuture<'_> {
119 let slot = block.slot();
120 let block_time = block.block_time();
121 let was_skipped = block.was_skipped();
122
123 async move {
124 if was_skipped {
125 return Ok(());
126 }
127
128 let rows = Self::take_slot_event(slot, block_time)
129 .into_iter()
130 .collect::<Vec<_>>();
131
132 if let Some(db_client) = db
133 && !rows.is_empty()
134 {
135 crate::spawn_tracked_write(async move {
136 crate::retry_clickhouse_write("instruction events", || {
137 write_instruction_events(Arc::clone(&db_client), rows.clone())
138 })
139 .await;
140 });
141 }
142
143 Ok(())
144 }
145 .boxed()
146 }
147
148 #[inline(always)]
149 fn on_load(&self, db: Option<Arc<Client>>) -> PluginFuture<'_> {
150 async move {
151 log::info!("Instruction Tracking Plugin loaded.");
152 if let Some(db) = db {
153 log::info!("Ensuring slot_instructions table exists...");
154 db.query(
155 r#"
156 CREATE TABLE IF NOT EXISTS slot_instructions (
157 slot UInt32,
158 timestamp DateTime('UTC'),
159 vote_instruction_count UInt64,
160 non_vote_instruction_count UInt64,
161 vote_transaction_count UInt32,
162 non_vote_transaction_count UInt32
163 )
164 ENGINE = ReplacingMergeTree(timestamp)
165 ORDER BY slot
166 "#,
167 )
168 .execute()
169 .await?;
170 log::info!("done.");
171 } else {
172 log::warn!(
173 "Instruction Tracking Plugin running without ClickHouse; data will not be persisted."
174 );
175 }
176 Ok(())
177 }
178 .boxed()
179 }
180
181 #[inline(always)]
182 fn on_exit(&self, db: Option<Arc<Client>>) -> PluginFuture<'_> {
183 async move {
184 if let Some(db_client) = db {
185 let rows = Self::drain_all_pending(None);
186 if !rows.is_empty() {
187 crate::retry_clickhouse_write("instruction events (exit flush)", || {
188 write_instruction_events(Arc::clone(&db_client), rows.clone())
189 })
190 .await;
191 }
192 crate::retry_clickhouse_write("instruction timestamp backfill", || {
193 backfill_instruction_timestamps(Arc::clone(&db_client))
194 })
195 .await;
196 }
197 Ok(())
198 }
199 .boxed()
200 }
201}
202
203async fn write_instruction_events(
204 db: Arc<Client>,
205 rows: Vec<SlotInstructionEvent>,
206) -> Result<(), clickhouse::error::Error> {
207 if rows.is_empty() {
208 return Ok(());
209 }
210 let mut insert = db
211 .insert::<SlotInstructionEvent>("slot_instructions")
212 .await?;
213 for row in rows {
214 insert.write(&row).await?;
215 }
216 insert.end().await?;
217 Ok(())
218}
219
220fn clamp_block_time(block_time: Option<i64>) -> u32 {
221 let Some(raw_ts) = block_time else {
222 return 0;
223 };
224 if raw_ts < 0 {
225 0
226 } else if raw_ts > u32::MAX as i64 {
227 u32::MAX
228 } else {
229 raw_ts as u32
230 }
231}
232
233fn instruction_vote_counts(transaction: &TransactionData) -> (u64, u64) {
234 let static_keys = transaction.transaction.message.static_account_keys();
235 let vote_program = vote_program_id();
236 let mut vote_count: u64 = 0;
237 let mut non_vote_count: u64 = 0;
238
239 let classify = |program_index: usize, vote_count: &mut u64, non_vote_count: &mut u64| {
240 if let Some(pid) = static_keys.get(program_index) {
241 if pid == &vote_program {
242 *vote_count = vote_count.saturating_add(1);
243 } else {
244 *non_vote_count = non_vote_count.saturating_add(1);
245 }
246 } else {
247 *non_vote_count = non_vote_count.saturating_add(1);
248 }
249 };
250
251 match &transaction.transaction.message {
252 VersionedMessage::Legacy(msg) => {
253 for ix in &msg.instructions {
254 classify(
255 ix.program_id_index as usize,
256 &mut vote_count,
257 &mut non_vote_count,
258 );
259 }
260 }
261 VersionedMessage::V0(msg) => {
262 for ix in &msg.instructions {
263 classify(
264 ix.program_id_index as usize,
265 &mut vote_count,
266 &mut non_vote_count,
267 );
268 }
269 }
270 }
271
272 if let Some(inner_sets) = transaction
273 .transaction_status_meta
274 .inner_instructions
275 .as_ref()
276 {
277 for set in inner_sets {
278 for ix in &set.instructions {
279 classify(
280 ix.instruction.program_id_index as usize,
281 &mut vote_count,
282 &mut non_vote_count,
283 );
284 }
285 }
286 }
287
288 (vote_count, non_vote_count)
289}
290
291async fn backfill_instruction_timestamps(db: Arc<Client>) -> Result<(), clickhouse::error::Error> {
292 db.query(
293 r#"
294 INSERT INTO slot_instructions
295 SELECT si.slot,
296 ss.block_time,
297 si.vote_instruction_count,
298 si.non_vote_instruction_count,
299 si.vote_transaction_count,
300 si.non_vote_transaction_count
301 FROM slot_instructions AS si
302 ANY INNER JOIN jetstreamer_slot_status AS ss USING (slot)
303 WHERE si.timestamp = toDateTime(0)
304 AND ss.block_time > toDateTime(0)
305 "#,
306 )
307 .execute()
308 .await?;
309
310 Ok(())
311}