Skip to content

feat(solana-indexer) PR 8: wire the decoder to the persistence seam - #4676

Merged
squadgazzz merged 30 commits into
mainfrom
solana-indexer/PR8-persist
Aug 13, 2026
Merged

feat(solana-indexer) PR 8: wire the decoder to the persistence seam#4676
squadgazzz merged 30 commits into
mainfrom
solana-indexer/PR8-persist

Conversation

@squadgazzz

@squadgazzz squadgazzz commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description

The decoder produced settlement events and dropped them. This wires them into the Persistence trait: per-slot event batches flow through persist_events together with the watermark advance, transactions that fail to decode are dead-lettered whole, and reverted transactions stop emitting events. The Persistence bodies stay documented no-ops until the Postgres adapter PR.

Changes

  • Decoder::run buffers events and dead letters per slot and flushes a slot once the stream moves FLUSH_HOLDBACK_SLOTS past 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.
  • A complete flushed slot advances the watermark to itself, resume is slot-granular (from_slot = watermark + 1), and a slot with no events advances it through write_watermark. Redelivery after a restart is absorbed by idempotent writes (spec §7, §10).
  • A transaction whose decode fails is dead-lettered whole through write_dead_letter: its events are not independent, so none of them persist and recovery replays the transaction by signature (spec §12).
  • Transactions with meta.err set 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 added to Persistence, and TransactionError re-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.

@squadgazzz squadgazzz changed the title solana-indexer: wire the decoder to the persistence seam feat(solana-indexer) PR 8: wire the decoder to the persistence seam Jul 27, 2026
@squadgazzz squadgazzz changed the title feat(solana-indexer) PR 8: wire the decoder to the persistence seam feat(solana-indexer): PR 8 wire the decoder to the persistence seam Jul 27, 2026
@squadgazzz squadgazzz changed the title feat(solana-indexer): PR 8 wire the decoder to the persistence seam feat(solana-indexer) PR 8: wire the decoder to the persistence seam Jul 27, 2026
@squadgazzz
squadgazzz marked this pull request as ready for review July 28, 2026 12:13
@squadgazzz
squadgazzz requested a review from a team as a code owner July 28, 2026 12:13
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @squadgazzz's task in 4m 21s —— View job


PR Review: wire the decoder to the persistence seam

  • Read changed files and diff against base
  • Analyze decoder/persistence wiring for correctness
  • Review tests
  • Post inline comments and summary

This is a clean, well-reasoned PR. The watermark = slot - 1 design is the subtle part and it holds up: because a slot is never marked done by its own transactions, any write_dead_letter for that slot is always durably written before a later slot advances the watermark past it — so the dead-letter vs. event-persist non-atomicity is genuinely absorbed by idempotent replay, as the description claims. The decode_failed propagation through decode_settlement / decode_settlements_finalized covers every parse/account/pairing failure, and recognized-but-eventless instructions (Initialize, ReclaimOrder) return Ok(vec![]) so they correctly don't trip the flag — only unknown discriminators do.

I left one inline comment: the revert guard treats a missing meta as "not reverted" (falls through to decoding), while post_token_balances a few lines down handles a missing meta defensively — an inconsistency worth pinning down.

Everything else — the reverted-tx skip, the empty-vs-non-empty watermark branch, the TransactionError re-export, and the documented no-op seam bodies — looks correct for this stage of the stack. Test coverage of decode is solid; the run-loop side effects being unobservable through the no-op seam is already acknowledged in the code.

Note: cargo check/nextest are blocked by the sandbox in this environment, so this review is based on static analysis only.

@squadgazzz
squadgazzz requested a review from tilacog July 28, 2026 12:15
Comment thread crates/solana-indexer/src/indexer/decoder.rs Outdated
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.
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")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not gate the respective identifiers with #[cfg(test)] then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we also log the decode failures?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are logged. Every failure site warns with the error, instruction index, and tx signature, see

let Ok((discriminator, _)) = recover_discriminator(&instruction.data) else {
decode_failed = true;
// Warn, not debug: this dead-letters the transaction, so it needs to
// be findable in the logs alongside the row.
tracing::warn!(
signature = %ctx.signature,
instruction_index = instruction.instruction_index,
err = %DecodeError::UnknownDiscriminator,

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.

Comment on lines +83 to +88
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, dropped it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should log information to identify the actual tx.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we should. Added.

@@ -117,13 +159,10 @@

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with a partition. One pass, no clones.

"decoded settlement events"
);
(
events.into_iter().map(DecodedEvent::Settlement).collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does decode_settlement not already return a DecodedEvent::Settlemen to avoid this conversion here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was all this error handling not added to the previous PR? Would have made reviewing simpler.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, it should've been done in the previous PR.

Comment on lines +30 to +34
// 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(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

squadgazzz and others added 5 commits August 5, 2026 09:49
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
@squadgazzz
squadgazzz requested a review from MartinquaXD August 5, 2026 13:12
Comment on lines +87 to +88
// A transaction of a later slot proves the pending slot is fully
// delivered, so it is safe to flush and mark done.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Added a warn log.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function has the same issue with the bool indicating an error that can easily be ignored.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, thanks! Updated.

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

BE-202

@MartinquaXD MartinquaXD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a nit remaining.

struct SlotBuffer {
slot: Slot,
events: Vec<DecodedEvent>,
dead_letters: Vec<(Signature, Slot)>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have the Slot in the tuples when we already have it on the SlotBuffer?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused about the meaning of "a late transaction" landing "in a newer slot buffer", can you clarify that please?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tilacog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to understand the design decisions better before approving this.

Comment on lines +87 to +88
// A transaction of a later slot proves the pending slot is fully
// delivered, so it is safe to flush and mark done.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread crates/solana-indexer/src/indexer/decoder.rs
struct SlotBuffer {
slot: Slot,
events: Vec<DecodedEvent>,
dead_letters: Vec<(Signature, Slot)>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused about the meaning of "a late transaction" landing "in a newer slot buffer", can you clarify that please?

Comment on lines +268 to +275
/// 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>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, makes sense. A decode failure now discards the whole transaction's events and only the dead letter is written.

Base automatically changed from solana-indexer/PR7.3-interface-parser to main August 11, 2026 09:09
@squadgazzz
squadgazzz requested a review from tilacog August 11, 2026 10:37
/// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the speed of the chain, I don't expect any issues with just 2 slots. Will be measuring that in prod anyway.

@squadgazzz
squadgazzz added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit e9761d6 Aug 13, 2026
22 checks passed
@squadgazzz
squadgazzz deleted the solana-indexer/PR8-persist branch August 13, 2026 09:48
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants