diff --git a/crates/solana-indexer/src/indexer/decoder/tests.rs b/crates/solana-indexer/src/indexer/decoder/tests.rs index 3519708829..f9ab760786 100644 --- a/crates/solana-indexer/src/indexer/decoder/tests.rs +++ b/crates/solana-indexer/src/indexer/decoder/tests.rs @@ -8,7 +8,8 @@ use { relevant_instructions, }, crate::{ - persistence::Persistence, + indexer::ingester::Ingester, + persistence::{Call, Persistence}, types::{ Signature, channel::StreamUpdate, @@ -21,14 +22,18 @@ use { InnerInstruction, InnerInstructions, Message, + SubscribeUpdate, + SubscribeUpdateTransaction, SubscribeUpdateTransactionInfo, Transaction, TransactionError, TransactionStatusMeta, + UpdateOneof, }, }, }, bytes::Bytes, + futures::stream, settlement_interface::{ Pubkey as InterfacePubkey, SettlementInstruction, @@ -36,6 +41,7 @@ use { pda::order::find_order_pda, }, solana_sdk::pubkey::Pubkey, + std::sync::{Arc, atomic::AtomicU64}, tokio::sync::mpsc::Sender, }; @@ -307,10 +313,21 @@ fn signature(n: u8) -> Signature { fn test_decoder(settlement: Pubkey, solflow: Pubkey) -> (Decoder, Sender) { let (sender, rx) = tokio::sync::mpsc::channel(16); - let decoder = Decoder::new(Persistence {}, rx, settlement, solflow); + let decoder = Decoder::new(Persistence::default(), rx, settlement, solflow); (decoder, sender) } +/// Wrap a transaction fixture in the proto envelope the ingester reads. +fn tx_update(slot: u64, info: SubscribeUpdateTransactionInfo) -> SubscribeUpdate { + SubscribeUpdate { + update_oneof: Some(UpdateOneof::Transaction(SubscribeUpdateTransaction { + slot, + transaction: Some(info), + })), + ..Default::default() + } +} + /// A transaction carrying one settlement instruction, so draining it also /// routes into `decode_settlement`. fn stream_tx(slot: Slot, signature: Signature, settlement: Pubkey) -> StreamUpdate { @@ -593,3 +610,87 @@ fn begin_and_finalize_settle_decode_to_settlement_finalized() { }] ); } + +/// Both components as one pipeline: proto `SubscribeUpdate`s go into the +/// ingester and come out of the decoder as persistence writes. This is the +/// only test spanning the channel, so it pins that what the ingester forwards +/// is what the decoder can consume, and it drives all three persistence +/// writes: the bare watermark for a slot with no events, the dead letter for +/// a failed decode, and the event batch of a slot cut off by the stream end. +#[tokio::test] +async fn ingester_to_decoder_persists_decoded_events() { + let (settlement, solflow) = (pubkey(1), pubkey(2)); + let (info, expected_uid, created_by) = create_order_tx(); + + // Slot 42: a reverted transaction, so the slot decodes to no events. + // The ingester drops transactions without a well-formed signature. + let mut reverted = info.clone(); + reverted.signature = signature(9).as_ref().to_vec(); + reverted.meta.as_mut().unwrap().err = Some(TransactionError { err: vec![1] }); + + // Slot 43: a good `CreateOrder` plus a settlement instruction with an + // unknown discriminator, so the slot persists its event and the + // transaction is dead-lettered. + let mut partial = info; + partial.signature = signature(10).as_ref().to_vec(); + let message = partial + .transaction + .as_mut() + .unwrap() + .message + .as_mut() + .unwrap(); + let settlement_index = message + .account_keys + .iter() + .position(|key| key.as_slice() == settlement.to_bytes()) + .unwrap(); + message.instructions.insert( + 0, + CompiledInstruction { + program_id_index: u32::try_from(settlement_index).unwrap(), + accounts: vec![1], + data: vec![0xFF], + }, + ); + + let (sender, receiver) = tokio::sync::mpsc::channel(16); + let persistence = Persistence::default(); + let mut ingester = Ingester::new( + stream::iter([Ok(tx_update(42, reverted)), Ok(tx_update(43, partial))]), + sender, + Arc::new(AtomicU64::new(0)), + ); + let mut decoder = Decoder::new(persistence.clone(), receiver, settlement, solflow); + + // Drive the ingester to the canned stream's end, which it reports as an + // error. `clean_stream_end_returns_stream_ended` pins which one. + ingester.run().await.unwrap_err(); + // Dropping the ingester closes the channel so the decoder's drain returns. + drop(ingester); + assert!(decoder.run().await.is_ok()); + + assert_eq!( + persistence.calls(), + vec![ + // Slot 42 produced no events and is flushed as a bare watermark + // once slot 43 starts. + Call::Watermark(Slot(42)), + // The unknown discriminator dead-letters the slot 43 transaction. + Call::DeadLetter { + signature: signature(10), + slot: Slot(43), + }, + // The stream ends inside slot 43, so its events persist with the + // watermark held at the previous slot and a restart replays it. + Call::PersistEvents { + events: vec![DecodedEvent::Settlement(SettlementEvent::OrderCreated { + order_uid: expected_uid, + owner: Pubkey::new_from_array([0x11; 32]), + created_by, + })], + watermark: Slot(42), + }, + ] + ); +} diff --git a/crates/solana-indexer/src/persistence.rs b/crates/solana-indexer/src/persistence.rs index 82d5a711df..149ca0fce9 100644 --- a/crates/solana-indexer/src/persistence.rs +++ b/crates/solana-indexer/src/persistence.rs @@ -13,12 +13,45 @@ use { std::ops::RangeInclusive, }; +/// One write the decoder asked for, captured so tests can assert the persist +/// contract without a database behind it. +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Call { + /// Events plus the watermark they ride with. + PersistEvents { + events: Vec, + watermark: Slot, + }, + /// A watermark advance on a transaction that decoded to no events. + Watermark(Slot), + /// A transaction whose decode failed. + DeadLetter { signature: Signature, slot: Slot }, +} + /// PostgreSQL persistence. Used by Decoder, Watchdog, and FinalizationWorker. /// /// Cheap to clone: wraps a shared pool. The method bodies are stubs. // TODO: hold `postgres: Arc` and implement the writes. -#[derive(Clone)] -pub(crate) struct Persistence {} +#[derive(Clone, Default)] +pub(crate) struct Persistence { + /// Shared with every clone, so a test can read what the decoder wrote after + /// handing its own clone to the component. + #[cfg(test)] + calls: std::sync::Arc>>, +} + +#[cfg(test)] +impl Persistence { + /// The writes this instance received, in order. + pub(crate) fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + fn record(&self, call: Call) { + self.calls.lock().unwrap().push(call); + } +} impl Persistence { /// Save decoded events and advance the slot watermark atomically. @@ -36,6 +69,11 @@ impl Persistence { watermark = %new_watermark, "persistence adapter missing, dropping decoded events" ); + #[cfg(test)] + self.record(Call::PersistEvents { + events, + watermark: new_watermark, + }); Ok(()) } @@ -44,6 +82,8 @@ impl Persistence { // No-op seam (no Postgres adapter). The adapter adds the monotonic // guard. tracing::warn!(%slot, "persistence adapter missing, dropping watermark write"); + #[cfg(test)] + self.record(Call::Watermark(slot)); Ok(()) } @@ -64,6 +104,8 @@ impl Persistence { %slot, "persistence adapter missing, dropping dead-letter row" ); + #[cfg(test)] + self.record(Call::DeadLetter { signature, slot }); Ok(()) }