solana-indexer: revert rolled-back transactions when their slots finalize - #4875
solana-indexer: revert rolled-back transactions when their slots finalize#4875squadgazzz wants to merge 14 commits into
Conversation
|
Claude finished @squadgazzz's task in 3m 33s —— View job ReviewReviewed the revert cascade, the finalization audit, the RPC probe, and the V3 migration. The cascade SQL itself (per-order delta aggregation, delete order,
Nothing else stood out — the migration, reader filters ( |
| pub async fn known_signatures(&self, signatures: &[Signature]) -> Result<Vec<bool>, Error> { | ||
| self.inner | ||
| .get_signature_statuses(signatures) | ||
| .await | ||
| .map(|response| { | ||
| response | ||
| .value | ||
| .into_iter() | ||
| .map(|status| status.is_some()) | ||
| .collect() | ||
| }) | ||
| } |
There was a problem hiding this comment.
get_signature_statuses has two properties that bite the audit:
-
256-signature cap. The RPC rejects more than 256 signatures per call. In steady state a single finalized slot carries few of our txs, but the catch-up range (
unfinalized_signatures(after, slot)whereafteris the persisted watermark after any downtime, or after accumulated audit-RPC failures) can easily exceed 256 and the whole call fails. Chunksignaturesinto batches of ≤256. -
searchTransactionHistorydefaults to false, so this only consults the recent-status cache (the "~150 slots" in the doc comment). A transaction that landed and finalized fine but has aged out of that cache returnsNonehere — i.e.falsedoes not reliably mean "rolled back"; it also means "too old to still be cached". See the companion note indecoder.rson why that turns into false-positive reverts.
There was a problem hiding this comment.
- 256-signature cap. [...]
Suggestion here makes sense.
2.
searchTransactionHistorydefaults to false [...]
I think #4877 is a more adequate mechanism, as mentioned in the other comment.
| let signatures = self.persistence.unfinalized_signatures(after, slot).await?; | ||
| let vanished = if signatures.is_empty() { | ||
| Vec::new() | ||
| } else { | ||
| match self.rpc.known_signatures(&signatures).await { | ||
| Ok(known) => signatures | ||
| .into_iter() | ||
| .zip(known) | ||
| .filter_map(|(signature, known)| (!known).then_some(signature)) | ||
| .collect(), | ||
| Err(err) => { | ||
| tracing::warn!(?err, "signature audit failed, finalization delayed"); | ||
| *finalized_through = Some(after); | ||
| return Ok(()); | ||
| } | ||
| } | ||
| }; | ||
| if vanished.is_empty() { | ||
| self.persistence.write_finalized_slot(slot).await?; | ||
| } else { | ||
| tracing::warn!(?vanished, "rolled-back transactions reverted"); | ||
| self.persistence.finalize_through(slot, &vanished).await?; |
There was a problem hiding this comment.
Risk of mass false-positive reverts when the audit range is large.
after is unbounded below: on the first finalized tick after boot it's the persisted watermark (finalized_slot().unwrap_or(slot)), and after an outage the live geyser stream jumps straight to the current finalized slot, so the first (after, slot] range can span thousands of slots. The same happens whenever audit-RPC failures accumulate and delay finalization.
known_signatures only checks the recent-status cache (~150 slots). Any transaction in that range that finalized normally but has already aged out of the cache comes back as not known → vanished → reverted. So an indexer that catches up over an old range would delete healthy settlements/trades and mark healthy orders is_reorged, exactly the data it just indexed.
The PR description's claim that "an RPC failure … never reverts on its own" holds for a single delayed tick, but not for a range that has grown past the cache window. Consider bounding the audit to the cache window (e.g. don't revert on slot - created_in_slot older than the cache; advance the watermark without auditing those), or pass searchTransactionHistory: true for the aged tail.
There was a problem hiding this comment.
I believe #4877 is the answer for such long missing slot ranges, so the audit introduced in this PR should not be authoritative over them.
There was a problem hiding this comment.
| response | ||
| .value | ||
| .into_iter() | ||
| .map(|status| status.is_some()) |
There was a problem hiding this comment.
Might be a corner case, but worth noting that the TransactionStatus object contained in status might not be Finalized, and the code here assumes so.
IMO the risk is near zero but we might be marking Confirmed slots as being finalized.
There was a problem hiding this comment.
The audit only asks whether the transaction exists, finality comes from the stream's finalized status. A confirmed-level answer just means the audit node lags our stream a bit, the transaction itself is fine.
| if slot <= after { | ||
| *finalized_through = Some(after); | ||
| return Ok(()); | ||
| } |
There was a problem hiding this comment.
Nit: Should this branch also call persistence.write_finalized_slot before returning?
There was a problem hiding this comment.
The write would be a no-op there. When that branch hits, after came from the persisted watermark (or the in-memory mirror, which only moves after a successful write), so the DB already stores slot or something newer. The one exception is a fresh DB, where after is just the current slot used as a starting point. Persisting that baseline would work too, but the next boot derives the same value again, so it buys nothing.
| UNION | ||
| SELECT created_by_tx FROM solana.order_pda | ||
| WHERE created_in_slot > $1 AND created_in_slot <= $2 AND created_by_tx IS NOT NULL | ||
| ORDER BY 1 |
There was a problem hiding this comment.
Is there a reason to order by the transaction signature here?
There was a problem hiding this comment.
It is now only required for tests, and I forgot to drop it. Done now.
| UPDATE solana.order_pda AS pda | ||
| SET amount_withdrawn = pda.amount_withdrawn - deltas.sell, | ||
| amount_received = pda.amount_received - deltas.buy | ||
| FROM ( | ||
| SELECT order_uid, SUM(sell_amount) AS sell, SUM(buy_amount) AS buy | ||
| FROM solana.trades WHERE tx_signature = $1 GROUP BY order_uid | ||
| ) AS deltas | ||
| WHERE pda.order_uid = deltas.order_uid |
There was a problem hiding this comment.
Might be a long shot and require further discussion beyond this PR, but what if pda.amount_withdrawn and pda.amount_received were a view over solana.trades instead of maintained columns?
Something like this:
CREATE VIEW solana.order_pda_sums AS
SELECT pda.order_uid,
COALESCE(SUM(t.sell_amount), 0) AS amount_withdrawn,
COALESCE(SUM(t.buy_amount), 0) AS amount_received
FROM solana.order_pda pda
LEFT JOIN solana.trades t USING (order_uid)
GROUP BY pda.order_uid;I think this could simplify some of the bookkeeping the indexer does, both on regular ops and during this reorg step... it would just come down to which rows exist (or don't) in solana.trades. Revert could skip the subtraction UPDATE entirely and only do the DELETEs.
Not sure about the read cost if something needs these sums for many orders at once, though.
There was a problem hiding this comment.
The delete-only revert is a nice property, but I would keep the columns for now. They mirror what the PDA stores on chain, and not every future change to those amounts has to come with a trade row. A view computed from trades would silently diverge the moment that happens. There is also the read side: solvable orders needs these sums for every open order, and I would rather not run the join plus aggregate on that path. If trades end up being the only writer we can switch to a view in a follow-up.
| "DELETE FROM solana.trades WHERE tx_signature = $1", | ||
| "DELETE FROM solana.settlements WHERE tx_signature = $1", | ||
| "DELETE FROM solana.dead_letter WHERE tx_signature = $1", | ||
| // Orders are marked, not deleted: the rows are the audit | ||
| // trail, and the stream re-delivering a re-landed creation | ||
| // clears the flag. | ||
| "UPDATE solana.order_pda SET is_reorged = true WHERE created_by_tx = $1", |
There was a problem hiding this comment.
Should we keep reorg'ed settlements and trades around too? For the same reasons we keep orders?
There was a problem hiding this comment.
An order row is user intent, a trade row is a record of something that happened on chain. After a reorg, the intent still stands, only the creation transaction is gone, and it usually lands again on the canonical fork. Then the order must return to solvable, so we keep the row and just flag it, the re-delivered creation clears the flag. Deleting also keeps the readers honest, otherwise every consumer of those tables would need an is_reorged filter. Same split as on EVM: events are hard-deleted on reorg, ethflow orders get the is_reorged mark.
ebe21a7 to
70fd3cd
Compare
…-rollback-cascade # Conflicts: # crates/cow-solana-rpc/src/lib.rs # crates/solana-indexer/src/indexer/decoder.rs # crates/solana-indexer/src/indexer/decoder/tests.rs # crates/solana-indexer/src/persistence.rs
Description
Reorg handling for the indexer, the second half of BE-203, stacked on #4854. Solana cannot reorg past finality, so the moment slots become final is the last place a reorg can be caught.
Confirmed transactions vanish only on an optimistic-confirmation safety failure, so the audit is a no-op in practice: one batched
getSignatureStatusescall per finalized tick, and none when the range holds no rows. An RPC failure delays finalization to the next tick, it never skips a range and never reverts on its own.Changes
is_reorged, atomically with the watermark advance. Marked orders are skipped by the solvable-orders query and the API reads, and a re-landed creation clears the flag, mirroring EVM'sis_reorgedononchain_placed_orderssolana.order_pdacarriescreated_by_tx,created_in_slot, andis_reorged, all creation provenance and reorg state on the on-chain sidecar next tocreated_by,solana.ordersstays untouched, plus slot indexes for the audit scanscow-solana-rpcgainsknown_signatures, the batched recent-status probeHow to test
New ignored postgres tests for the cascade, and the pipeline test drives the audit through a mock RPC.
Related issues
BE-203