feat(solana-indexer) PR 8: wire the decoder to the persistence seam - #4676
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
The parsing-generic branch is merged and its ref deleted, so the branch pin would break the next cargo update. Main's interface types the push amount as u64, dropping the byte-array decode on our side.
…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.
…runcating The order/push zip silently truncated to the shorter side and the pull sum saturated. Both are layout violations, so the pair is skipped with a warning. Also merges the no-event instruction arms, drops the intermediate push collect, and inlines the test instruction helper.
| @@ -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.
There was a problem hiding this comment.
why the change from expect though? when they are no longer dead_code it would trigger a warning reminding you to remove it — is it doing that for tests?
| } = 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 @@ | |||
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.
The CreateOrder and BeginSettle/FinalizeSettle fixtures go through the client crate's builders as a dev-dependency, so the tests round-trip the real encoder into our parser instead of hand-rolling bytes. The invalid-instruction fixtures stay hand-rolled: they craft data no builder would produce, and they double as the wire-layout pin.
…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.
There was a problem hiding this comment.
I agree we should buffer by slot, but I’m concerned about flushing only when a new Settlement program transaction occurs. This means the latest events for a slot S might not be seen until events from a later slot S+N arrive, where N is arbitrary.
It might be worth adding bare slot‑update messages for confirmed slots to the channel, so a new slot can trigger a flush. (Also, this aligns with our plan to use an unlogged Postgres table to track the chain tip.)
There was a problem hiding this comment.
Good catch! At some point, I was thinking about this, but then switched to another task and forgot to finish it. Thanks! Updated the code.
There was a problem hiding this comment.
Also, added a two-slot hold-back with per-slot buffers, since a transaction delivered a couple of slots late still joins its own unflushed buffer, which keeps it replayable after a crash.
| // 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
MartinquaXD
left a comment
There was a problem hiding this comment.
just a nit remaining.
| struct SlotBuffer { | ||
| slot: Slot, | ||
| events: Vec<DecodedEvent>, | ||
| dead_letters: Vec<(Signature, Slot)>, |
There was a problem hiding this comment.
Why do we have the Slot in the tuples when we already have it on the SlotBuffer?
There was a problem hiding this comment.
I'm a bit confused about the meaning of "a late transaction" landing "in a newer slot buffer", can you clarify that please?
There was a problem hiding this comment.
Mainly because two can differ when the stream delivers a transaction late. For example, if the buffer is open for slot 100, then a delayed transaction from slot 99 can arrive, so it folds into the slot-100 buffer instead of triggering a flush. If that transaction fails to decode and we stamp its dead-letter row with the buffer's slot, the row would claim it landed in slot 100 while it actually landed in 99. We can technically switch to BTreeMap<Slot, SlotBuffer>, but it looks like unnecessary machinery. Added a comment explaining that.
tilacog
left a comment
There was a problem hiding this comment.
I'd like to understand the design decisions better before approving this.
| // 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.
I agree we should buffer by slot, but I’m concerned about flushing only when a new Settlement program transaction occurs. This means the latest events for a slot S might not be seen until events from a later slot S+N arrive, where N is arbitrary.
It might be worth adding bare slot‑update messages for confirmed slots to the channel, so a new slot can trigger a flush. (Also, this aligns with our plan to use an unlogged Postgres table to track the chain tip.)
| struct SlotBuffer { | ||
| slot: Slot, | ||
| events: Vec<DecodedEvent>, | ||
| dead_letters: Vec<(Signature, Slot)>, |
There was a problem hiding this comment.
I'm a bit confused about the meaning of "a late transaction" landing "in a newer slot buffer", can you clarify that please?
| /// The events that did decode from a transaction in which at least one | ||
| /// instruction failed to decode. The caller persists them and dead-letters | ||
| /// the transaction for replay. | ||
| #[derive(Debug, PartialEq, Eq)] | ||
| struct PartialDecode<T> { | ||
| events: Vec<T>, | ||
| } | ||
|
|
There was a problem hiding this comment.
I have some reservations about persisting events from partially decoded slots or transactions, since that could compromise the integrity of our indexing state.
I think we're better off scoping success or failure at the transaction level, so we dead-letter a whole transaction rather than a subset of its events.
There was a problem hiding this comment.
Yeah, makes sense. A decode failure now discards the whole transaction's events and only the dead letter is written.
…rsist # Conflicts: # crates/solana-indexer/src/indexer/decoder.rs # crates/solana-indexer/src/indexer/decoder/tests.rs
| /// A transaction delivered up to this many slots late still joins its own | ||
| /// unflushed buffer instead of racing the watermark. | ||
| const FLUSH_HOLDBACK_SLOTS: u64 = 2; | ||
|
|
There was a problem hiding this comment.
I think 2 slots is fine for indexing speed, I'm just not sure whether it would add noticeable lag anywhere else, mainly the autopilot.
I don't know how to gauge this, though.
There was a problem hiding this comment.
Given the speed of the chain, I don't expect any issues with just 2 slots. Will be measuring that in prod anyway.
Description
The decoder produced settlement events and dropped them. This wires them into the
Persistencetrait: per-slot event batches flow throughpersist_eventstogether with the watermark advance, transactions that fail to decode are dead-lettered whole, and reverted transactions stop emitting events. ThePersistencebodies stay documented no-ops until the Postgres adapter PR.Changes
Decoder::runbuffers events and dead letters per slot and flushes a slot once the stream movesFLUSH_HOLDBACK_SLOTSpast it, through later transactions or slot-status messages. The hold-back gives late-delivered transactions a window to join their slot's still-open buffer.from_slot = watermark + 1), and a slot with no events advances it throughwrite_watermark. Redelivery after a restart is absorbed by idempotent writes (spec §7, §10).write_dead_letter: its events are not independent, so none of them persist and recovery replays the transaction by signature (spec §12).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_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.