Skip to content

Use REST spenttxouts for block indexing, plus consistency fixes and RocksDB multi-CF - #217

Open
shesek wants to merge 20 commits into
Blockstream:new-indexfrom
shesek:202604-indexer-spenttxouts
Open

Use REST spenttxouts for block indexing, plus consistency fixes and RocksDB multi-CF#217
shesek wants to merge 20 commits into
Blockstream:new-indexfrom
shesek:202604-indexer-spenttxouts

Conversation

@shesek

@shesek shesek commented May 23, 2026

Copy link
Copy Markdown
Collaborator

This PR adds a new indexing mode using the Bitcoin Core spenttxouts REST endpoint to look up spent prevouts and switches to REST for raw block contents (in both indexing modes).

The existing indexing mode operates in two phases: first populate txstore, then populate history while looking up spent prevouts in txstore. This makes indexing read-heavy and introduces an ordering requirement: before a block can be indexed into history, it and all its ancestors must first be populated into txstore.

The new indexer fetches each block alongside its spent prevouts directly from the Bitcoin Core binary REST API using the block and spenttxouts endpoints, then writes the complete txstore+history DB rows for each block. This allows blocks to be indexed in any order with full parallelism, as a write-only workload with no local RocksDB reads.

The new indexer is now the default operating mode for Bitcoin, but the old indexer is selected automatically when spenttxouts is not supported: for Elements/Liquid, where it is the only mode, or for Bitcoin Core prior to v30.

Commits can be grouped into:

  • Cleanup: removes obsolete operating modes that are no longer supported, to reduce maintenance burden when making indexer/schema changes. This includes DB migration, BLK import support and light-mode. (923cd01, 56babed, 2967e60)

  • Groundwork: prepares shared infrastructure for both indexing modes: switch to ordered prevout consumption, extract reusable code and introduce HeaderWork, plus a perf fix. (cf403ea, d60d985, 495bf2b, a2d6df9)

  • Core work: adds binary REST client support and the spenttxouts-based indexer, and tunes RocksDB for increased write throughput. (9cc2622, 4a03aba, e08b42f, e93d17d, 2938ada)

  • Consistency: adapts crash recovery, tip publication, stale undo and RocksDB durability for out-of-order block indexing. (ed7af75, 9222fcb, c1a23c9)

Consistency and crash recovery

Out-of-order block indexing required adapting crash recovery to use a new startup stale cleanup mechanism, where Electrs compares the daemon's best chain with the D completion markers and cleans up any stale blocks before normal indexing work. During reorgs, the common ancestor t is now persisted before stale history deletion so a mid-way crash will recover from a safe tip and complete cleanup.

Store::open() also includes a startup fail-safe mechanism from an earlier recovery design (that was discarded), that trims chain visibility down to the fully indexed contiguous prefix. The current design does not depend on this, but I kept it anyway to guard against unexpected/invalid state. This could alternatively panic instead of trim.

Stale undo now uses local RocksDB data instead of the current bitcoind. This makes recovery safe in multi-backend deployments, where Electrs might reconnect to a different daemon that does not have the stale block.

The PR also adds an AI-assisted document describing the safety model, enumerating potential crash-recovery failure cases, and explaining how they are handled. This helped validate the implementation and identified the two (pre-existing) failure cases fixed by 9222fcb and c1a23c9. I wasn't sure whether to include this in the final PR, but ended up keeping it as reference for humans/AIs making consistency/safety-sensitive changes.

RocksDB Column Families

The index now uses a single RocksDB database with four column families: default, txstore, history and cache.

This was necessary to resolve a consistency/durability issue with the prior multi-DB design, and also desirable regardless.

Potential Followups

  • If we could get Elements to add spenttxouts support (a simple +109 patch to rest.cpp plus docs/tests), we could consider supporting recent Core/Elements versions only and remove the old indexer mode entirely.
  • We could boost indexer performance even more using @RCasatta's bitcoin_slices crate for allocation-free block parsing. I initially thought to include this alongside the new indexer, but it's big enough of a change to warrant its own PR. We could take inspiration from bindex which uses bitcoin_slices.

Backward Compatibility

This PR breaks DB compatibility and requires a complete reindex.

@shesek shesek changed the title Use REST spenttxouts for block indexing Use REST spenttxouts for block indexing, plus consistency fixes and RocksDB multi-CF May 23, 2026
@shesek
shesek force-pushed the 202604-indexer-spenttxouts branch from 02b2052 to 39d3ca3 Compare May 23, 2026 07:21
@shesek

shesek commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Shoutout and a big thanks to @romanz that made this possible! 🙌

Comment thread src/config.rs
Comment thread src/daemon.rs Outdated
Comment thread src/daemon.rs Outdated
"REST spenttxouts failed for {} (is bitcoind running with -rest=1?): {}",
blockhash, e
))
})?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we have retries here like above in pub getblock() (above starting line 581)?

Comment thread src/new_index/schema.rs
return Ok(());
}

#[cfg(not(feature = "liquid"))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

--no-spenttxouts is still a supported Bitcoin mode, but the non-Liquid test harness now always sets use_spenttxouts: true (tests/common.rs line 125). That leaves this refactored legacy Bitcoin path untested; Liquid exercises a similar path, but under different chain types/cfgs.

Could we add at least one Bitcoin integration test or TestRunner variant with use_spenttxouts: false, covering sync plus a spend/history/utxo query?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

My thinking was that Liquid exercises the relevant code paths for the legacy indexer, and that it could be sufficient since the Bitcoin/Liquid-specific differences all live in the lower-level helpers shared by both indexing modes.

But I agree it's probably not good enough. It seems best to also test this against an older Bitcoin Core release that didn't support spenttxouts at all, which is what I implemented in 8958426.

Comment thread src/config.rs
let mut log = stderrlog::new();
// Base verbosity is 2 (Info), each -v flag adds one level:
// no flags = Info, -v = Debug, -vv = Trace
log.verbosity(2 + m.occurrences_of("verbosity") as usize);

@EddieHouston EddieHouston Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm wondering if we should add a log.modules(["electrs", "electrs_macros", etc]); here...
Reason is that when in trace mode... the new spenttxouts calls route through ureq-proto which hex-dumps all of the /rest/block/*.bin and /rest/spenttxouts/*.bin responses... and I was piping the logs to a file and ran out of disk space when they got to 1.3 TB 😅 Might be nice to scope the verbosity to just modules we "care about".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Huh yeah that's no good >.< Should definitely be addressed.

Rather than hiding dependency logging entirely, it could be useful to keep warn-level logs for dependencies (plus potentially info/debug too in higher -v levels, perhaps with a new -vvv mode) while hiding trace-level logs which cause the log size blow up.

However it seems that stderrlog doesn't support setting a per-module logging level, so we have to settle for either hiding dependency logging entirely or showing it at the global logging level.

There's a compromise we can make: have no -v (warn+info) and one -v (debug) enable logs for all modules, make -vv (trace) set log.modules(["electrs"]), and treat -vvv as enabling trace for all modules.

This would result in quite a surprising behavior, though: no -v and one -v would show dependency logging, while increasing to -vv suddenly hides it. But still, it seems better than always hiding it?

Another alternative we can consider is switching from stderrlog to env_logger, which does support per-module verbosity levels.

Comment thread src/daemon.rs Outdated
.iter()
.map(|hash| json!([hash, /*verbose=*/ false]))
.collect();
let url = format!("http://{}/rest/block/{}.bin", self.addr, blockhash);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Codex-discovered review note (blocking): new-index now supports --daemon-rpc-fallback-addr, but REST requests here always use the fixed primary addr. If JSON-RPC fails over, block and spenttxouts fetching can continue targeting the unavailable primary.

During the rebase, could we make REST backend selection consistent with daemon failover—ideally trying the active RPC backend first and the alternate backend for retryable failures? It would be good to cover this with a test where the primary becomes unavailable during indexing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Implemented in 90a62fa.

I considered a few approaches for this, and ended up letting Connection be the source of truth for backend selection and failover. The REST client reuses the active Connection address and triggers the Connection reconnection mechanism on transport errors.

Recycling is implemented through a new RestAgent wrapper. This also required putting the agent behind an Arc<Mutex<T>> for interior mutability and synchronization, but the cost should be negligible since the lock is only held for the max-age check and cheap Arc cloning in the typical case (or the ureq::Agent re-initialization when recycling).

Comment thread src/new_index/schema.rs
let previous_txos: Vec<TxOut> = daemon
.get_spent_txouts(block_entry.entry.hash())?
.into_iter()
.flatten()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Codex-discovered review note (blocking): Flattening the response loses the per-transaction boundaries before they are validated. index_block() verifies the total number of consumed prevouts, but a malformed response with the correct total and incorrect per-transaction counts could associate prevouts with the wrong inputs.

Could we first verify that (1) the response contains block.txdata.len() - 1 entries and (2) each entry has exactly as many prevouts as the corresponding non-coinbase transaction has inputs? Then flattening it for the existing indexing interface is safe.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This codebase generally assumes bitcoind returns valid consistent replies, there are quite a few other places where we could assert more defensively for malformed replies but currently don't.

I'm not saying we necessarily shouldn't validate in this particular case, but not sure where the line is crossed. wdyt?

Comment thread src/config.rs Outdated
initial_sync_batch_size: value_t_or_exit!(m, "initial_sync_batch_size", usize),
db_cache_index_filter_blocks: m.is_present("cache_index_filter_blocks"),
#[cfg(not(feature = "liquid"))]
use_spenttxouts: !m.is_present("no_spenttxouts"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Codex-discovered review note (recommended): spenttxouts is now the default, but startup still accepts older Bitcoin Core versions and only discovers missing REST support once indexing begins. Could we perform an early capability check and return an actionable error such as: “spenttxouts indexing requires Bitcoin Core 30+ with -rest=1; use --no-spenttxouts for older Core”?

This would distinguish an unsupported Core version, disabled REST, and a transient connection failure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I implemented a variant of your suggestion in e93d17d: it errors if REST is disabled, but checks for spenttxouts support to automatically select the indexing mode, instead of the --no-spenttxouts CLI option. When it's unsupported, it logs a warning suggesting to upgrade Bitcoin Core.

Do you think that's better, or would you prefer the explicit --no-spenttxouts and an error when the setting is incompatible with the daemon?

@EddieHouston

Copy link
Copy Markdown
Collaborator

Codex-discovered review note (blocking): The consistency document is valuable, but this PR does not appear to add automated tests for the new completion-marker and crash-recovery behavior. Given that out-of-order indexing is the reason these mechanisms changed, I think we should turn the principal recovery cases into tests before merging.

At minimum:

  1. txstore complete, history incomplete;
  2. later blocks complete while an earlier block is incomplete;
  3. completed work exists beyond persisted t;
  4. restart during stale/reorg cleanup.

Each test should reopen the database and verify both the visible tip and history/UTXO results, rather than only checking marker rows.

shesek and others added 18 commits July 28, 2026 17:32
Remove the V1-to-V2 migration script.

This branch makes several DB schema changes and no longer supports a
migration path for old DBs, requiring a reindex instead.
Remove blk*.dat support and the `FetchFrom` dispatch around it,
leaving support for RPC-based syncing only.

Drop the now-obsolete `--blocks-dir` and `--jsonrpc-import` config, daemon
BLK-file helpers and initial-sync switching logic.
Drop light-mode indexing and its RPC-based querying. The indexer now always
stores the data needed for local block and transaction queries.

This changes the compatibility marker format which breaks DB compatibility, but
doesn't bump `DB_VERSION`. It is bumped once in a later commit, when switching
to column families.
Consume prevouts as ordered iterables during block indexing instead of building
a `HashMap` and looking up each prevout by outpoint. This avoids extra map
build/lookup work and matches the shape needed later to consume the
`spenttxouts` format.

The shared shape is one ordered prevout vector per block: txstore-based lookup
partitions the flat batch `multi_get` result into that shape, and the
`spenttxouts` mode will flatten Bitcoin Core's per-block/per-transaction results
into the same shape.
Split the batch-level add/index helpers into `add_block()` and `index_block()`
to make the row collectors reusable by the later `spenttxouts` indexing mode.
Have `headers_to_process()` return `HeaderWork` entries recording whether each
header still needs txstore rows, history rows, or both. This avoids taking
another lock to re-check block status after fetching blocks, and will be
shared by both indexing modes.
Remove pre-existing unnecessary cloning. This cleanup is unrelated to the
`spenttxouts` changes and only affects the existing indexing mode.
- Switch block fetching to the binary REST `block` endpoint, making REST the
  only supported block fetch source for both Bitcoin Core and Elements.

- Add REST client support for `spenttxouts`, currently unused until
  the `spenttxouts` indexing mode is added in the next commit.

- Reuse the bounded daemon Rayon pool for parallel REST requests.

- Start test regtest nodes with REST enabled.
The new mode fetches each block's spent prevouts using binary REST, avoids
local TXO lookups during indexing, and allows blocks to be processed in parallel
without chain-order dependencies.

Bitcoin Core indexing now defaults to the spenttxouts-based mode, with
`--no-spenttxouts` as a fallback for older releases that don't support it
(<v30). Elements stays on the existing txstore-based indexing mode.

The integration tests node was updated to Bitcoin Core v30.2.
…o prevent OOM

With pin_l0_filter_and_index_blocks_in_cache enabled, each L0 SST file
pins its bloom filter and index blocks in the block cache. With the old
settings (trigger=64, 256 MB write buffers), L0 could accumulate to the
stop threshold — at ~9.75 MB pinned per file, this consumed ~3.7 GB
across 3 DBs, overflowing the 2 GB block cache and spilling to heap,
triggering OOM during mainnet initial sync around block 750k.

Lower the trigger to 32 and set slowdown=×3 (96 files) and stop=×4
(128 files). With 128 MB write buffers (~4.9 MB filter blocks per file),
pinned metadata at the stop threshold is ~1.88 GB across 3 DBs — within
the 2 GB cache at all times.

Set the write buffer CLI flag accordingly: --db-write-buffer-size-mb=128
Since the spenttxouts-based indexer can process blocks in parallel and complete
them out of order, consistency now requires crash recovery based on completion
markers and revised tip-publication rules:

- Derive startup chain visibility from the persisted `t` chain plus
  txstore/history completion markers. The first daemon-aware `update()`
  then sweeps history-complete stale blocks before normal indexing work.

- Change reorg handling to persist the common ancestor in the DB prior to
  undoing stale block data, so a crash during stale cleanup restarts from the
  rollback point and completes recovery.

- Store the block height alongside the block header in `B` rows, so that
  stale recovery can rebuild `HeaderEntry`s from RocksDB alone.

  This changes the DB schema but does not bump `DB_VERSION`. It is bumped once in
  a later commit, when switching to column families.

- Add an indexer consistency and crash recovery document describing the current
  safety model, recovery assumptions and caveats. At this point stale cleanup
  still depends on the daemon for stale block contents, and using separate
  RocksDB databases can still lead to a corrupted state because cross-DB
  WAL-disabled writes may be flushed implicitly and independently.
Reconstruct stale `BlockEntry`s and spent prevouts from local txstore
`B`, `X`, `T`, and `O` rows instead of fetching them from the daemon.

This is used for both live reorg cleanup and startup stale cleanup, so undo no
longer depends on the currently connected daemon having the stale block.
- Store `default`, `txstore`, `history`, and `cache` as column families in one
  physical RocksDB.

- Move compatibility metadata and persisted tip `t` into the default column family.

- Flush txstore/history bulk writes with RocksDB atomic flush before publishing
  `t`, replacing the separate-DB flush sequence and removing the remaining
  cross-DB durability caveat.

- Bump `DB_VERSION` for the schema changes in this branch.
(to be squashed into `feat(daemon): Add Daemon REST client for block/spenttxouts endpoints`)
(to be squashed into `cleanup(index): Remove BLK-file support and related config`)
(to be squashed into `feat(daemon): Add Daemon REST client for block/spenttxouts endpoints`)
(to be squashed into `feat(index): Add spenttxouts-based indexing mode`)
(to be squashed into `fix(index): Redesign crash recovery and stale block cleanup`)
shesek and others added 2 commits July 28, 2026 17:32
- Check for daemon REST support on startup.

- Probe for `spenttxouts` support and use the default indexing mode when
  available. Otherwise, warn and fall back to legacy indexing.

- Remove `--no-spenttxouts`.
- Retain the bulk L0 compaction trigger at 32, but slow and stop writes at
  48/64 files and at 8/32 GiB of pending compaction debt. This lets RocksDB
  regulate ingestion when compaction cannot keep up.
- Expose the target SST size and bulk-load pending-compaction limits as
  tuning knobs, with defaults of 1024 MB and 8/32 GiB.
- Cap max_subcompactions at four to limit compaction concurrency and memory
  pressure under high db_parallelism settings.

Spenttxouts processing remains fully parallel, with no application-level
polling or chunking.

Co-authored-by: Philippe McLean <philippe.mclean@gmail.com>
@shesek
shesek force-pushed the 202604-indexer-spenttxouts branch from 39d3ca3 to 2938ada Compare July 28, 2026 19:33
@shesek

shesek commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

The consistency document is valuable, but this PR does not appear to add automated tests [..]

The reason I added the consistency document is that testing the full indexer behavior through the high-level Indexer interface can get quite tricky. Testing some edge-case invariants requires mocking the bitcoind side, while others require crashing the indexer midway through and validating electrs' state across restarts.

What can be tested more easily is the underlying Store interface, which doesn't fully cover the indexer behavior but captures some aspects of it. I actually did have the visibility_stops_at_first_one_sided_completion test initially, but it felt somewhat superficial and I ended up not including it. But thinking this through again, I agree that it's useful and added it back in b5ef6e6 along with two other tip-handling tests, which together cover your "at minimum" cases 1 to 3.

  1. restart during stale/reorg cleanup.

Testing case 4 gets into trickier territory. I'm not sure whether adding it or the other higher-level tests is worth the complexity at this stage, but I'm open to discussing this. One thing that makes this more appealing is that the same testing infrastructure we would need here (bitcoind mocking and cross-restart tests) would also be valuable for testing edge-case mempool behaviors. I would still leave it for a followup PR though.

verify both the visible tip and history/UTXO results

We already have other tests tying the visible tip to history/UTXO results. I opted to keep the Store recovery tests simpler and only check the visible tip and completion markers. Do you think that's enough, or should they be extended to cover history/UTXO too?

@shesek

shesek commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased and addressed review comments. I kept the fixes as separate commits to aid review, to be squashed later.

@EddieHouston

Copy link
Copy Markdown
Collaborator

Rebased and addressed review comments. I kept the fixes as separate commits to aid review, to be squashed later.

Thank you, will check them out.

One thing I notice is that cargo install --path . is now failing with:

Installing electrs v0.4.1 (/Users/ehouston/freeelectrslogs/electrs)
   Updating git repository `https://github.com/Blockstream/rust-electrum-client`
   Updating git repository `https://github.com/shesek/electrumd`
error: failed to compile `electrs v0.4.1 (/Users/ehouston/freeelectrslogs/electrs)`, intermediate artifacts can be found at `/Users/ehouston/freeelectrslogs/electrs/target`.
To reuse those artifacts with a future compilation, set the environment variable `CARGO_TARGET_DIR` to that path.

Caused by:
 package `electrs v0.4.1 (/Users/ehouston/freeelectrslogs/electrs)` does not have a dependency named `corepc-node`                                                                                                   

Looks like the cause is 8958426 ("test(index): Test legacy indexer with Bitcoin Core 29"), which moved the Core version selector out of the dev-dependency and into a default-on feature:

[features]
default = ["test-core-30"]
test-core-30 = ["corepc-node/30_2"]

[dev-dependencies]
corepc-node = { version = "0.12", features = ["download"] }   # was ["download", "30_2"]

and cargo install strips dev-dependencies, so once test-core-30 activates it references a dependency that isn't in the install-time manifest. cargo build and cargo test keep dev-deps, which is why CI here is green... only cargo install breaks.

Reproduced on 2938ada

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.

3 participants