feat(solana-indexer) PR 8: wire the decoder to the persistence seam - #4676
feat(solana-indexer) PR 8: wire the decoder to the persistence seam#4676squadgazzz wants to merge 13 commits into
Conversation
|
Claude finished @squadgazzz's task in 4m 21s —— View job PR Review: wire the decoder to the persistence seam
This is a clean, well-reasoned PR. The I left one inline comment: the revert guard treats a missing Everything else — the reverted-tx skip, the empty-vs-non-empty watermark branch, the Note: |
…on id to the wire
…arser' into solana-indexer/PR8-persist
Drops the pending-adapter and later-PR breadcrumbs, the spec anchor, and the leftover cast comment resolved by the i64 pass-through.
| @@ -1,10 +1,9 @@ | |||
| #![expect(dead_code)] | |||
| #![allow(dead_code, reason = "dead in the lib build, exercised by tests")] | |||
There was a problem hiding this comment.
Why not gate the respective identifiers with #[cfg(test)] then?
There was a problem hiding this comment.
These are dead only until the wiring PR makes the binary call them. I was choosing between the allow line and #[cfg(test)] on each item (not needed in the future PRs). So the latter is just an intermediate step we can avoid.
| } = update; | ||
| self.decode(&inner, slot, signature); | ||
| let (events, decode_failed) = self.decode(&inner, slot, signature); | ||
| tracing::debug!(slot = %slot, event_count = events.len(), "decoded events"); |
There was a problem hiding this comment.
shouldn't we also log the decode failures?
There was a problem hiding this comment.
They are logged. Every failure site warns with the error, instruction index, and tx signature, see
services/crates/solana-indexer/src/indexer/decoder.rs
Lines 252 to 259 in fa6488d
The dead-letter row stays minimal (signature + slot), recovery only needs to know what to re-fetch. Right now, a failed tx produces a second warn from the persistence stub saying the write was dropped, which will be removed in the DB adapter PR.
| // Stream resume is slot-granular (`from_slot = watermark + 1`), and | ||
| // the slot may still have more transactions in flight, so marking it | ||
| // done here could skip its remaining transactions after a crash. | ||
| // Writing `slot - 1` on every transaction only ever marks fully | ||
| // delivered slots. A redelivery of this slot after a restart is | ||
| // absorbed by idempotent writes. |
There was a problem hiding this comment.
Seems like some of the complications (watermark - 1, spammy DB updates) could be resolved by buffering the incoming stream until we have all events for a given slot. Is there a good reason to issue multiple DB writes per slot?
There was a problem hiding this comment.
Makes sense. Those are buffered now per slot and flushed once a transaction of a later slot arrives.
| /// a flag reporting whether any settlement instruction failed to decode. | ||
| /// The settlement half runs through the pure [`decode_settlement`], the | ||
| /// SolFlow half is a stub. | ||
| #[tracing::instrument(skip_all, fields(slot = %slot, signature = %signature))] |
There was a problem hiding this comment.
I can understand instrumenting the slot but the signature seems unnecessary. This data will be added to every trace downstream. Is the signature really that important?
There was a problem hiding this comment.
Not really, dropped it.
There was a problem hiding this comment.
I didn't know that you find transactions on the block explorer via the signature (as opposed to the tx hash for EVM). In that case I'd be fine with instrumenting the signature. Sorry for the confusion.
| // instead of skipping: replay re-fetches by signature, and | ||
| // `getTransaction` returns the meta. | ||
| let Some(meta) = tx.meta.as_ref() else { | ||
| tracing::warn!("transaction update without meta"); |
There was a problem hiding this comment.
We should log information to identify the actual tx.
There was a problem hiding this comment.
Yep, we should. Added.
| @@ -117,13 +159,10 @@ impl Decoder { | |||
There was a problem hiding this comment.
we are iterating over the instructions twice and clone them when we don't have to. There could be a loop pushing an instruction either into a settlement contract vector or a solflow vector.
There was a problem hiding this comment.
Replaced with a partition. One pass, no clones.
| "decoded settlement events" | ||
| ); | ||
| ( | ||
| events.into_iter().map(DecodedEvent::Settlement).collect(), |
There was a problem hiding this comment.
why does decode_settlement not already return a DecodedEvent::Settlemen to avoid this conversion here?
There was a problem hiding this comment.
decode_settlement handles only the settlement program's events. DecodedEvent is the enum over both SolFlow and settlement programs. If decode_settlement returned DecodedEvent itself, its signature would say it can also emit SolFlow events, which it can't.
| ctx: &TxContext, | ||
| resolve_order: impl Fn(&Pubkey) -> Option<ResolvedOrder>, | ||
| ) -> Vec<SettlementEvent> { | ||
| ) -> (Vec<SettlementEvent>, bool) { |
There was a problem hiding this comment.
This interface is very error prone. The function should return a Result so that the caller has to acknowledge that an error happened to get at the partially decoded data (if handling partially decoded transactions even makes sense).
There was a problem hiding this comment.
Updated. It now returns Result<Vec<SettlementEvent>, PartialDecode>.
| // be findable in the logs alongside the row. | ||
| tracing::warn!( | ||
| instruction_index = instruction.instruction_index, | ||
| err = %DecodeError::UnknownDiscriminator, |
There was a problem hiding this comment.
why was all this error handling not added to the previous PR? Would have made reviewing simpler.
There was a problem hiding this comment.
True, it should've been done in the previous PR.
| // No-op seam (no Postgres adapter). The adapter writes the | ||
| // events and advances the watermark in one SQL transaction: append rows | ||
| // as INSERT ON CONFLICT DO NOTHING, the watermark UPDATE guarded with | ||
| // WHERE slot < $new_watermark. | ||
| Ok(()) |
There was a problem hiding this comment.
what's the reason to replace todo!() with a no-op here? This doesn't seem good.
At the very least we should emit a warning.
There was a problem hiding this comment.
run_drains in this PR drives run() over a transaction that fails to decode, which reaches write_dead_letter. With todo!() that test panics. The pipeline test in #4677 then asserts all three writes through a call recorder.
…arser' into solana-indexer/PR8-persist # Conflicts: # crates/solana-indexer/src/indexer/decoder/tests.rs
…xer/PR8-persist # Conflicts: # crates/solana-indexer/src/indexer/decoder/tests.rs
| // A transaction of a later slot proves the pending slot is fully | ||
| // delivered, so it is safe to flush and mark done. |
There was a problem hiding this comment.
This seems very reasonable to me but we have no experience with the RPC behavior yet. Maybe we should add a log that informs us when we receive events out of order. 🤔
There was a problem hiding this comment.
Makes sense. Added a warn log.
| // Partial decode is expected: events that did decode persist and | ||
| // the watermark advances. Recovery replays the whole transaction | ||
| // by signature, and idempotent writes absorb the overlap. | ||
| self.persistence.write_dead_letter(signature, slot).await?; |
There was a problem hiding this comment.
The comment says that this advances the watermark, right?
Don't know for sure but it sounds like that could lead to skipped events when we fail to decode an event and the service crashes before processing the remaining events in the slot.
But it seems like the existing buffering logic should be easy to extend to also buffering the dead_letter stuff.
There was a problem hiding this comment.
The skip couldn't quite happen since the watermark only ever advances at the slot flush, never per transaction. But I updated the code as you suggested. Dead letters now buffer with the slot, and the flush writes them first, before the events and the watermark advance, so a crash can never leave the watermark past a slot.
| /// a flag reporting whether any settlement instruction failed to decode. | ||
| /// The settlement half runs through the pure [`decode_settlement`], the | ||
| /// SolFlow half is a stub. | ||
| #[tracing::instrument(skip_all, fields(slot = %slot, signature = %signature))] |
There was a problem hiding this comment.
I didn't know that you find transactions on the block explorer via the signature (as opposed to the tx hash for EVM). In that case I'd be fine with instrumenting the signature. Sorry for the confusion.
| tx: SubscribeUpdateTransactionInfo, | ||
| slot: Slot, | ||
| signature: Signature, | ||
| ) -> (Vec<DecodedEvent>, bool) { |
There was a problem hiding this comment.
This function has the same issue with the bool indicating an error that can easily be ignored.
There was a problem hiding this comment.
Oh yeah, thanks! Updated.
…xer/PR8-persist # Conflicts: # crates/solana-indexer/src/indexer/decoder.rs
Description
The decoder produced settlement events and dropped them. This wires them into the
Persistenceseam: events and the slot watermark flow throughpersist_events, decode failures land in a dead-letter seam, and reverted transactions stop emitting events. ThePersistencebodies stay documented no-ops until the Postgres adapter PR.Changes
Decoder::runhands each transaction's events topersist_eventsand advances the watermark, or callswrite_watermarkwhen a transaction yields no events.slot - 1: stream resume is slot-granular (from_slot = watermark + 1), so marking a slot done while it may still have transactions in flight would skip its remainder after a crash. Redelivery after restart is absorbed by idempotent writes (spec §7, §10).meta.errset decode to nothing: a failed Solana transaction rolls back every account write, so the decoder must not emit state-changing events (spec §4). A revert-attribution event is a later PR.write_dead_letter(signature, slot, "decoder_error")entry, while the events that did decode still persist and the watermark still advances (spec §12).write_dead_letteradded toPersistence, andTransactionErrorre-exported from the wire types.How to test
New and updated unit tests.
Related issues
Stacked on #4666. The SQL bodies land with the Postgres adapter PR later.