Skip to content

solana-indexer: backfill from RPC history when the replay window is exceeded - #4877

Open
squadgazzz wants to merge 29 commits into
mainfrom
solana-indexer/be-204-gap-scan
Open

solana-indexer: backfill from RPC history when the replay window is exceeded#4877
squadgazzz wants to merge 29 commits into
mainfrom
solana-indexer/be-204-gap-scan

Conversation

@squadgazzz

@squadgazzz squadgazzz commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Description

The stream provider replays only ~150 slots, so a restart after real downtime resubscribes from the live tip and silently skips everything in between. This PR fills the gap from RPC instead: walk the program's signature history back to the stored in the DB watermark, re-fetch each transaction, and push it through the same decode and flush path streamed transactions take.

The cost scales with the program's transactions, not with chain length, since empty slots cost nothing. If RPC itself cannot serve the gap, the backfill retries and then panics rather than silently accepting a hole: the watermark never passes an unscanned slot, so the restarted process reruns the recovery until RPC serves it, and the outage shows up as a crashloop in alerting.

Same invariants as the EVM event handler's getLogs catch-up: the database watermark is the only resume truth, and the backfill commits before the live stream attaches.

First half of BE-204, stacked on #4875. Dead-letter replay follows separately.

Changes

  • A rejected watermark resume triggers the RPC backfill and a resubscribe, and a backfill that keeps failing panics instead of skipping the gap
  • A converter rebuilds the stream's wire shape from getTransaction payloads, pinned by an equivalence test against the streamed form
  • cow-solana-rpc gains signatures_for_address and transaction

How to test

New unit tests and ignored postgres tests, including an end-to-end backfill through a mock RPC.

Related issues

BE-204

@linear-code

linear-code Bot commented Sep 7, 2026

Copy link
Copy Markdown

BE-204

@squadgazzz
squadgazzz force-pushed the solana-indexer/be-204-gap-scan branch from b61edc0 to 8a1a37c Compare September 11, 2026 07:35
@squadgazzz
squadgazzz force-pushed the solana-indexer/be-203-rollback-cascade branch from ebe21a7 to 70fd3cd Compare September 11, 2026 07:35
@squadgazzz
squadgazzz force-pushed the solana-indexer/be-204-gap-scan branch from 8a1a37c to 87b9097 Compare September 11, 2026 08:27
@squadgazzz
squadgazzz force-pushed the solana-indexer/be-204-gap-scan branch from 87b9097 to 301a216 Compare September 11, 2026 08:56
@squadgazzz
squadgazzz marked this pull request as ready for review September 11, 2026 12:36
@squadgazzz
squadgazzz requested a review from a team as a code owner September 11, 2026 12:36
@squadgazzz
squadgazzz requested a review from tilacog September 11, 2026 12:36
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @squadgazzz's task in 3m 31s —— View job


Review

Solid, well-documented change — the decode/flush reuse via a stream-shaped convert() is a clean way to keep the backfill and live paths in sync, and the watermark-advances-to-tip invariant is sound. I left three inline comments, all around one theme: how RPC failures during backfill are classified and recorded.

  • Transient RPC error aborts the whole backfill and records the gap as permanently lost (backfill.rs backfill/backfill_inner). A single getTransaction/signatures_for_address timeout or 429 short-circuits the scan, writes a lost_slot_ranges row, and drops to LiveTip — converting a recoverable gap into an unrecoverable one. Worth distinguishing retryable errors from genuine unavailability and retrying before giving up.
  • Early pagination termination on a filtered short page (signatures_since). page_len is post-filter, so a full page with one unparsable signature looks like the last page and silently truncates the walk.
  • Unrecorded gap when the bounding calls fail (backfill error handler). The range is logged but not persisted, yet the watermark still advances past it on LiveTip.

Non-blocking: the convert equivalence test covers only a single top-level CreateOrder — the ALT-loaded-address, inner-instruction (CPI), and BeginSettle/FinalizeSettle pairing paths through convert aren't exercised, and those are exactly where the RPC wire shape is most likely to diverge from the stream's. A fixture with a settlement + inner instructions would harden the pin.
· solana-indexer/be-204-gap-scan

Comment on lines +39 to +62
pub(crate) async fn backfill(&self) -> Result<(), PersistenceError> {
let result = self.backfill_inner().await;
if let Err(err) = &result {
tracing::error!(?err, "backfill failed, recording the gap as lost");
match (
self.persistence.last_indexed_slot().await,
self.rpc.slot().await,
) {
(Ok(Some(from)), Ok(tip)) => {
if let Err(err) = self
.persistence
.record_lost_range(from, Slot(tip), "backfill failed")
.await
{
tracing::error!(?err, "failed to record the lost range");
}
}
(watermark, tip) => {
tracing::error!(?watermark, ?tip, "failed to bound the lost range");
}
}
}
result
}

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.

Any error out of backfill_inner records the gap as lost and (via run.rs) drops to Resume::LiveTip, permanently skipping the range. But backfill_inner fails on the first transient RPC error — a single signatures_for_address/transaction timeout or 429 aborts the whole scan (? on lines 97, 137, 68). So a momentary RPC blip converts a fully recoverable gap into an "unrecoverable" lost_slot_ranges row and jumps past it on the live tip.

The PR intends lost_slot_ranges for ranges "RPC cannot serve" (pruned history), but the code doesn't distinguish that from a retryable failure. Consider retrying the backfill a bounded number of times (the loop is already paced by STREAM_RETRY) before declaring the gap lost, or only recording as lost when the RPC error is genuinely non-retryable. Otherwise transient failures silently drop protocol transactions.

Fix this →

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.

Fair point. Fixed in 108b062.

Comment on lines +138 to +148
let page_len = page.len();
before = page.last().map(|(signature, _)| *signature);
let fresh = entries.len();
entries.extend(
page.into_iter()
.take_while(|(_, slot)| Slot(*slot) > watermark)
.map(|(signature, slot)| (Slot(slot), signature)),
);
let reached_watermark = entries.len() - fresh < page_len;
if reached_watermark || page_len < SolanaRPC::SIGNATURES_PAGE {
return Ok(entries);

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.

page_len is the length after signatures_for_address has dropped any unparsable signatures. The termination check page_len < SolanaRPC::SIGNATURES_PAGE treats a short page as "history exhausted" — but a full 1000-signature page with even one malformed entry filters down to 999, ending the walk early and silently missing older in-window history below it.

To key the "last page" decision on the raw page size rather than the filtered count, have signatures_for_address report the unfiltered length (or return the raw count alongside the parsed entries). Rare in practice, but it produces a silent gap that no lost_slot_ranges row records.

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.

Right, good catch. Fixed in 108b062.

Comment on lines +56 to +59
(watermark, tip) => {
tracing::error!(?watermark, ?tip, "failed to bound the lost range");
}
}

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.

When the bounding calls (last_indexed_slot/rpc.slot) themselves fail, the gap is logged but not recorded to lost_slot_ranges, yet backfill() still returns Err, so run.rs proceeds to Resume::LiveTip and advances the watermark past the gap on the next confirmed slot. Net result: an unrecorded, silently-skipped range — the exact "silently accepted" outcome the table exists to prevent. At minimum the watermark (from) is already known here from backfill_inner's own read; consider persisting a lost range with whatever bound is available rather than dropping the record entirely.

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.

Reworked in 108b062.

Comment thread crates/cow-solana-rpc/src/lib.rs Outdated
Comment on lines +134 to +158
/// One page of an address's transaction signatures, newest first,
/// starting below `before` when given. The node serves deep history, so
/// repeated calls walk arbitrarily far back.
pub async fn signatures_for_address(
&self,
address: &Pubkey,
before: Option<Signature>,
) -> Result<Vec<(Signature, u64)>, Error> {
let config = solana_rpc_client::rpc_client::GetConfirmedSignaturesForAddress2Config {
before,
limit: Some(Self::SIGNATURES_PAGE),
..Default::default()
};
let page = self
.inner
.get_signatures_for_address_with_config(address, config)
.await?;
Ok(page
.into_iter()
.filter_map(|status| {
let signature = status.signature.parse().ok()?;
Some((signature, status.slot))
})
.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.

Just an idea: should this method handle/abstract pagination internally so callers don't have to?

I see signatures_since does it already, so I don't have a strong preference.
(maybe worth revisiting once more consumers need it.)

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.

Kept it page-level on purpose, since the stop condition (the watermark) lives in the caller, and internalizing the loop would either fetch history we do not need or grow a predicate callback. It would make sense to revisit when a second consumer shows up. Introduced a new type, tho: SignaturesPage.

Comment thread crates/solana-indexer/src/run.rs Outdated
Comment on lines +90 to +104
let backfiller = {
let rpc = SolanaRPC::new_with_timeout_and_commitment(
&config.rpc.endpoint,
config.rpc.request_timeout,
CommitmentConfig::confirmed(),
);
let (_closed, rx) = mpsc::channel(1);
Decoder::new(
persistence.clone(),
rpc,
rx,
settlement_program,
solflow_program,
)
};

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.

nit: should this be encapsulated as Decoder::backfiller(...)?

smth like

fn backfiller(config: &Config, persistence: Postgres) -> Self 

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.

Moved to Decoder::rpc_driven.

Comment on lines +47 to +58
if result.is_err()
&& let (Ok(Some(from)), Ok(tip)) = (
self.persistence.last_indexed_slot().await,
self.rpc.slot().await,
)
&& let Err(err) = self
.persistence
.record_lost_range(from, Slot(tip), "backfill failed")
.await
{
tracing::error!(?err, "failed to record the lost range");
}

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.

Should this be considered a terminal condition / fatal error?

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.

Crashlooping will likely get to this point faster, but we will be warned via alerting right away. Added a panic.

Comment thread crates/solana-indexer/src/indexer/decoder/backfill.rs Outdated
Comment thread crates/solana-indexer/src/indexer/decoder/backfill.rs
…-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
…exer/be-204-gap-scan

# Conflicts:
#	crates/cow-solana-rpc/src/lib.rs
#	crates/solana-indexer/Cargo.toml
#	crates/solana-indexer/src/indexer/decoder.rs
#	crates/solana-indexer/src/indexer/decoder/tests.rs
@squadgazzz

Copy link
Copy Markdown
Contributor Author

@claude review again please, i dropped the lost ranges logic.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @squadgazzz's task in 3m 8s —— View job


Review

The lost-ranges logic is cleanly removed, and the previous round's three findings are all resolved:

  • Transient RPC failures now retry (BACKFILL_ATTEMPTS) and only panic on persistent failure — no more converting a recoverable gap into an unrecoverable record.
  • Page termination is keyed on SignaturesPage.full (raw length), so a filtered short page no longer truncates the walk early.
  • No more spurious lost_slot_ranges rows: the watermark never advances past an unscanned slot, and a stuck backfill crash-loops (visible in alerting) instead of silently skipping.

The panic-over-skip invariant is sound, and reusing the stream's decode/flush_slot path via convert() keeps the two ingestion routes in lockstep.

I left one inline finding:

  • Commitment mismatch in the signature scan (cow-solana-rpc/src/lib.rs). signatures_for_address defaults to finalized while the tip (rpc.slot()) and transaction() use confirmed. The scan lists signatures only to the finalized tip but the watermark advances to the confirmed tip, so tracked txs in the (finalized, confirmed] window (~32 slots) can be silently skipped — the hole this PR aims to prevent. Suggested pinning the scan to confirmed.

Non-blocking: the convert equivalence test still exercises only a single top-level CreateOrder — the ALT-loaded-address, inner-instruction (CPI), and BeginSettle/FinalizeSettle paths through convert remain uncovered, and those are where the RPC wire shape is most likely to diverge from the stream's.
· solana-indexer/be-204-gap-scan

Comment on lines +148 to +152
let config = solana_rpc_client::rpc_client::GetConfirmedSignaturesForAddress2Config {
before,
limit: Some(Self::SIGNATURES_PAGE),
..Default::default()
};

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.

Commitment mismatch can still skip a small window silently. This config leaves commitment unset, so getSignaturesForAddress runs at the node's default (finalized). But the backfill's tip comes from rpc.slot(), which uses the client's confirmed commitment, and transaction() above also fetches at confirmed.

So backfill_inner scans signatures only up to the finalized tip, then advances the watermark to the confirmed tip (write_last_indexed_slot(tip)). Any tracked settlement/order transaction in the (finalized_tip, confirmed_tip] window — up to ~32 slots — is confirmed but not yet listed by getSignaturesForAddress, so it is neither backfilled nor re-streamed (the resume starts at watermark + 1, past it). That's a silently-skipped gap, the exact outcome this PR sets out to prevent.

Pinning the scan to confirmed keeps the signature listing, the tip, and the transaction fetch on the same commitment:

Suggested change
let config = solana_rpc_client::rpc_client::GetConfirmedSignaturesForAddress2Config {
before,
limit: Some(Self::SIGNATURES_PAGE),
..Default::default()
};
let config = solana_rpc_client::rpc_client::GetConfirmedSignaturesForAddress2Config {
before,
limit: Some(Self::SIGNATURES_PAGE),
commitment: Some(CommitmentConfig::confirmed()),
..Default::default()
};

Fix this →

Base automatically changed from solana-indexer/be-203-rollback-cascade to main September 11, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants