Skip to content

fix(chain): let the indexer decide how far to rescan - #2262

Open
LLFourn wants to merge 1 commit into
bitcoindevkit:masterfrom
LLFourn:reindex-fixed-point
Open

fix(chain): let the indexer decide how far to rescan#2262
LLFourn wants to merge 1 commit into
bitcoindevkit:masterfrom
LLFourn:reindex-fixed-point

Conversation

@LLFourn

@LLFourn LLFourn commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Description

IndexedTxGraph::reindex was a single pass over TxGraph::full_txs, which iterates a HashMap. That made it nondeterministic: the same graph reindexed twice could produce two different last_revealed results.

Each match inside KeychainTxOutIndex::_index_txout bumps last_revealed and then calls replenish_inner_index, whose stop index is last_revealed + 1 + lookahead. So a match widens the derived window the remaining outputs are judged against. An output far enough out to need that widening was skipped if it happened to be visited first, and no pass revisited it.

Concretely, with lookahead 1000 and an empty frontier the derived set is 0..1001. Given one tx paying index 900 and another paying 1850: visited 900-first, the window grows to 1901 and both are found; visited 1850-first, 1850 is missed and stays missed. Same inputs, different index, decided by HashMap iteration order.

The usual objection — that callers should reveal before reindexing — does not hold. The lookahead exists precisely to catch indices the persisted frontier does not know about: a restored wallet, another signer on the same descriptor, an externally built PSBT paying one of our far indices. Whenever the lookahead does its job the frontier moves mid-walk, so the order dependence is present in the intended use, not just in misuse.

The change

Add Indexer::rescan, handed the whole TxGraph and returning the indexing it produced:

fn rescan<A>(&mut self, graph: &TxGraph<A>) -> Self::ChangeSet
where
    Self::ChangeSet: Merge,

The default implementation offers every full transaction and floating output to index_tx / index_txout exactly once — all an indexer needs when what it recognizes is fixed up front — and IndexedTxGraph::reindex becomes a call to it. KeychainTxOutIndex overrides it and looks repeatedly, stopping when a pass leaves its revealed frontier unmoved.

Whether to look more than once belongs to the indexer, because the indexer is the only thing that knows whether its recognition set can still grow. Two things follow from putting it there rather than in IndexedTxGraph:

  • An indexer that does not widen what it matches is walked exactly once, as today. SpkTxOutIndex is unaffected. There is no convergence requirement imposed on implementors, and no way for a third-party indexer to be spun forever by a loop it never asked for.
  • KeychainTxOutIndex can key the loop on its own frontier rather than on whether a changeset came back empty. That matters: the changeset also carries staged spk cache entries, which move without the frontier moving. A changeset.is_empty() loop therefore spends an extra full walk on the ordinary restore path. I measured this — with persist_spks = true, restoring via from_changeset with a correct frontier takes 1 pass keyed on the frontier versus 2 keyed on changeset emptiness.

Notes to the reviewers

On the test shape. The regression test uses one transaction with two of our outputs rather than two transactions. A two-transaction test would depend on graph walk order — the very thing that is unreliable — and so would pass a single-pass implementation about half the time, which is a test that fails to fail. index_tx walks tx.output, a Vec, in vout order, so a single tx paying a far index at vout 0 and a near one at vout 1, against an empty frontier, misses the far output on every pass on every run. It is deterministic by construction; I verified it fails against the old implementation on five consecutive runs (left: Some(9), right: Some(15)).

On cost. KeychainTxOutIndex::rescan re-walks the whole graph per look, so it is O(looks × txs), with looks bounded by the number of distinct frontier advances plus one. Measured: the settled/restore case is 1 look; the recovery case in the test (empty frontier, far output only reachable after the near one lands) is 3. Making a later look re-examine only the outputs that did not already match would need the indexer to say what to re-offer rather than just whether, which is a bigger change than this fix.

Trait change. rescan is a defaulted method, so existing Indexer implementations keep compiling unchanged. It is generic over the anchor A, which makes Indexer no longer object-safe — nothing in the workspace uses dyn Indexer.

Known adjacent gap, not addressed here. index_tx_graph_changeset — used by insert_tx, insert_txout, apply_update and apply_block_relevant — is still a single pass and has the same order dependence. Feeding the one-transaction case above through insert_tx yields last_revealed = Some(9) and only one of the two outpoints, so a live wallet can drop the far UTXO until the next restart re-runs reindex. It needs the incremental paths to go through the same mechanism rather than their own copy; I kept this PR to reindex to stay reviewable, and am happy to follow up.

Changelog notice

  • Added: Indexer::rescan, which indexes an entire TxGraph and lets an implementation decide how many looks that takes. Defaulted, so existing implementations are unaffected.
  • Fixed: IndexedTxGraph::reindex no longer depends on HashMap iteration order. It previously made a single pass and could miss outputs at derivation indices that only came into range after another output in the same walk advanced the lookahead; KeychainTxOutIndex now looks until its revealed frontier stops moving.

Checklists

All Submissions:

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.71%. Comparing base (6d03fc3) to head (38b3abc).
⚠️ Report is 11 commits behind head on master.

Files with missing lines Patch % Lines
crates/chain/src/indexer.rs 0.00% 12 Missing ⚠️
crates/chain/src/indexer/keychain_txout.rs 84.61% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2262      +/-   ##
==========================================
+ Coverage   78.65%   78.71%   +0.05%     
==========================================
  Files          30       31       +1     
  Lines        5909     5966      +57     
  Branches      279      282       +3     
==========================================
+ Hits         4648     4696      +48     
- Misses       1185     1194       +9     
  Partials       76       76              
Flag Coverage Δ
rust 78.71% <50.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`IndexedTxGraph::reindex` was a single pass over `TxGraph::full_txs`,
which iterates a `HashMap`, so the same graph reindexed twice could
produce two different `last_revealed` results. Each match inside
`KeychainTxOutIndex::_index_txout` bumps `last_revealed` and replenishes
the lookahead, widening the derived window the remaining outputs are
judged against. An output far enough out to need that widening was
skipped whenever it happened to be visited first, and no pass revisited
it.

This is not merely misuse of the API. The lookahead exists precisely to
catch indices the persisted frontier does not know about -- a restored
wallet, another signer on the same descriptor, an externally built PSBT
paying one of our far indices -- so whenever it does its job the frontier
moves mid-walk, and the order dependence is present in the intended use.

Add `Indexer::rescan`, which is handed the whole graph and returns what
indexing it produced. The default implementation offers every full
transaction and floating output once, which is all an indexer needs when
what it recognizes is fixed up front; `reindex` becomes a call to it.
`KeychainTxOutIndex` overrides it and looks repeatedly, stopping when a
pass leaves its revealed frontier unmoved.

Putting the loop behind the trait rather than in `IndexedTxGraph` keeps
the decision where the knowledge is. An indexer that does not widen
what it matches is walked exactly once, as before, so the looping
cannot leak into implementations that have no use for it. And
`KeychainTxOutIndex` can key the loop on its own frontier instead of on
whether a changeset came back empty: the changeset also carries staged
spk cache entries, which move without the frontier moving, so an
emptiness test spends an extra full walk on the ordinary restore path.

The regression test uses a single transaction with both outputs rather
than two transactions, because the graph's walk order is a `HashMap`
order: a two-transaction test would pass a single-pass implementation
about half the time. `index_tx` walks `tx.output` in vout order, so the
far index at vout 0 is always judged against the initial window and
always missed until the near index at vout 1 lifts the frontier.
@LLFourn
LLFourn force-pushed the reindex-fixed-point branch from 32162e1 to 38b3abc Compare August 19, 2026 05:16
@LLFourn LLFourn changed the title fix(chain): make IndexedTxGraph::reindex a fixed point fix(chain): let the indexer decide how far to rescan Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant