Skip to content

index: batch db writes during initial sync#34489

Draft
furszy wants to merge 10 commits into
bitcoin:masterfrom
furszy:2026_index_batch_processing
Draft

index: batch db writes during initial sync#34489
furszy wants to merge 10 commits into
bitcoin:masterfrom
furszy:2026_index_batch_processing

Conversation

@furszy

@furszy furszy commented Feb 3, 2026

Copy link
Copy Markdown
Member

Pending on #34897 review. Please go there first.


Decouples part of #26966.

Right now, index initial sync writes to disk for every block, which is not the best for HDD. This change batches those, so disk writes are less frequent during initial sync. This also cuts down cs_main contention by reducing the number of NextBlockSync calls (instead of calling it for every block, we will call it once per block window), making the node more responsive (IBD and validation) while indexes sync up. On top of that, it lays the groundwork for the bigger speedups, since part of the parallelization pre-work is already in place.

Just as a small summary:

  • Batch DB writes instead of flushing per block, which improves sync time on HDD due to the reduced number of disk write operations.
  • Reduce cs_main lock contention, which improves the overall node responsiveness (and primarily IBD) while the indexes threads are syncing.
  • Lays the groundwork for the real speedups, since part of index: initial sync speedup, parallelize process #26966 parallelization pre-work is introduced here as well.
  • Reduces the number of generated LDB files. See l0rinc's comment.

@DrahtBot

DrahtBot commented Feb 3, 2026

Copy link
Copy Markdown
Contributor

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/34489.

Reviews

See the guideline for information on the review process.

Type Reviewers
Concept ACK arejula27, l0rinc, polespinasa
Stale ACK optout21

If your review is incorrectly listed, please copy-paste <!--meta-tag:bot-skip--> into the comment that the bot should ignore.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #34440 (Refactor CChain methods to use references, tests by optout21)
  • #32875 (index: handle case where pindex_prev equals chain tip in NextSyncBlock() by HowHsu)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

LLM Linter (✨ experimental)

Possible typos and grammar issues:

  • // attached while m_synced is still false, and it would not be -> // attached while m_synced is still false, and it would not be indexed. [fragmentary comment missing its conclusion; adding "indexed." completes the sentence and makes the intent clear]

  • // No need to handle errors in Commit. If it fails, the error will be already be -> // No need to handle errors in Commit. If it fails, the error will already be -> duplicate word "be" makes the sentence ungrammatical; remove the extra "be" to restore correct syntax.

Possible places where named args for integral literals may be used (e.g. func(x, /*named_arg=*/0) in C++, and func(x, named_arg=0) in Python):

  • WaitForHeight(&index, blocking_height + 2, 5s) in src/test/blockfilter_index_tests.cpp

2026-03-08 18:11:08

@furszy furszy force-pushed the 2026_index_batch_processing branch from 4734e8d to cf065aa Compare February 3, 2026 04:42
@DrahtBot

DrahtBot commented Feb 3, 2026

Copy link
Copy Markdown
Contributor

🚧 At least one of the CI tasks failed.
Task iwyu: https://github.com/bitcoin/bitcoin/actions/runs/21616547248/job/62296358632
LLM reason (✨ experimental): IWYU (include-what-you-use) reported a failure in the test script, causing the CI to fail.

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@fjahr fjahr 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.

This relates to what you wrote here right? It would be helpful that you check if it solves the LevelDB file issue in coinstatsindex and I am also curious about your benchmark results because @l0rinc did not find that this was leading to a speed up.

Comment thread src/index/base.cpp Outdated
Comment thread src/index/base.h
@l0rinc

l0rinc commented Feb 3, 2026

Copy link
Copy Markdown
Contributor

Concept ACK, I prefer this over #33306 - and will investigate if this solves that problem once my benchmarking servers free up.

because @l0rinc did not find that this was leading to a speed up.

I experimented with something similar in https://github.com/l0rinc/bitcoin/pull/37/changes, will compare it against this change. I wrote:

indexes: batch index writes: 34188s, 211M, 8 files
It solved the fragmentation, but didn't speed up anything.
I still think this is a better direction than adding manual compactions.

Most likely since writing isn't the bottleneck but MuHash calculations were.

@furszy

furszy commented Feb 3, 2026

Copy link
Copy Markdown
Member Author

This relates to what you wrote here right? It would be helpful that you check if it solves the LevelDB file issue in coinstatsindex and I am also curious about your benchmark results because @l0rinc did not find that this was leading to a speed up.

@fjahr the comment was merely an excuse to decouple the DB writes batching code out of #26966 rather than me having a formed opinion in favor or against #33306. That's why I didn't mention it in the PR description. Maybe the changes complement each other.

To be crystal clear, just updated the PR description with further details on why this change worth alone, independently on #33306.

To summarize it here, the goal of the changes are:

  • Batch DB writes instead of flushing per block, which will improve sync time on HDD due to the reduced number of IO operations.
  • Reduce cs_main lock contention, which orthogonally improves the overall node responsiveness (and primarily IBD) while the indexes threads are syncing.
  • Lays the groundwork for the real speedups, since part of index: initial sync speedup, parallelize process #26966 parallelization pre-work is introduced here as well.

@furszy furszy force-pushed the 2026_index_batch_processing branch 2 times, most recently from dd76491 to a2f8447 Compare February 3, 2026 20:25
@furszy

furszy commented Feb 3, 2026

Copy link
Copy Markdown
Member Author

Updated per feedback from @hebasto (thanks!).
Changed <cinttypes> include for <cstdint> to make IWYU happy.

@hebasto

hebasto commented Feb 3, 2026

Copy link
Copy Markdown
Member

Updated per feedback from @hebasto (thanks!). Changed <cinttypes> include for <cstdint> to make IWYU happy.

I guess, this is a bug in IWYU caused by include-what-you-use/include-what-you-use@44480a2.

UPDATE: Fixed in #34498.

@maflcko

maflcko commented Feb 4, 2026

Copy link
Copy Markdown
Member

Can you run all unit and functional tests on all commits? Or do they time out?

@furszy furszy force-pushed the 2026_index_batch_processing branch from a2f8447 to 8826900 Compare February 4, 2026 15:17
@furszy

furszy commented Feb 4, 2026

Copy link
Copy Markdown
Member Author

Can you run all unit and functional tests on all commits? Or do they time out?

Bad squash, my bad. Thanks for the heads up. Fixed.
Also rebased the branch to pick up the CI changes.

@Jhackman2019

Jhackman2019 commented Feb 8, 2026

Copy link
Copy Markdown

Builds clean on ARM64 (Pi 5, Debian Bookworm). All index-related functional tests and blockfilter_index unit tests pass.
:)

Comment thread src/index/txindex.cpp Outdated
@furszy furszy force-pushed the 2026_index_batch_processing branch from 8826900 to 6f66ff7 Compare February 8, 2026 16:55
Comment thread src/index/base.cpp Outdated
@optout21

optout21 commented Feb 9, 2026

Copy link
Copy Markdown
Contributor

I am confused as to how this batching relates to the batching logic of the orginal code.
Looking at the pre-change BaseIndex::Sync(), it seems that Commit/WriteBatch occured only every 30 seconds (or at the chain tip), not after each block. This change does a WriteBatch after every 500 blocks, which in fact may be even more frequent. The original commit-after-every-30-secs is still there, so I don't see how this change makes less writes. Maybe I misunderstand the change.

            if (!ProcessBlock(pindex)) return; // error logged internally
            ...
            if (current_time - last_locator_write_time >= SYNC_LOCATOR_WRITE_INTERVAL) {
                SetBestBlockIndex(pindex);
                last_locator_write_time = current_time;
                // No need to handle errors in Commit. See rationale above.
                Commit();
            }

@furszy

furszy commented Feb 9, 2026

Copy link
Copy Markdown
Member Author

I am confused as to how this batching relates to the batching logic of the orginal code. Looking at the pre-change BaseIndex::Sync(), it seems that Commit/WriteBatch occured only every 30 seconds (or at the chain tip), not after each block. This change does a WriteBatch after every 500 blocks, which in fact may be even more frequent. The original commit-after-every-30-secs is still there, so I don't see how this change makes less writes. Maybe I misunderstand the change.

            if (!ProcessBlock(pindex)) return; // error logged internally
            ...
            if (current_time - last_locator_write_time >= SYNC_LOCATOR_WRITE_INTERVAL) {
                SetBestBlockIndex(pindex);
                last_locator_write_time = current_time;
                // No need to handle errors in Commit. See rationale above.
                Commit();
            }

Commit() only persists the locator (i.e. "how far the index has synced") so we can resume from that point after a restart. It does not flush or batch the per-block digested data.

The block data is written inside CustomAppend() which is called from ProcessBlock() per block, and it writes to the db immediately.

We're not replacing locator update time nor merging it with something else here, we're only batching something that previously wasn't batched at all.

Also, using a block window instead of reusing the existing time-based window is intentional. Time-based flushing makes memory usage hard to predict, and difficult to test (see #32878), because the amount of buffered data depends on runtime speed, which does not necessarily correlate with available memory (batched data stays in memory until it's flushed..). A fixed block window provides predictable memory usage and deterministic behavior, which is overall better and also integrates nicely with multiple workers sync (#26966). I would argue in favor of ditching the time window in the future, but.. that's not something related to this PR.

@furszy furszy force-pushed the 2026_index_batch_processing branch from 6f66ff7 to d0f793a Compare February 9, 2026 19:58
@furszy

furszy commented Feb 9, 2026

Copy link
Copy Markdown
Member Author

Updated per feedback, plus added test coverage for the introduced changes. Thanks @optout21.

@optout21

optout21 commented Feb 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for the explanations, and apologies for asking trivial/irrelevant questions.

Commit() only persists the locator (i.e. "how far the index has synced") so we can resume from that point after a restart.
The block data is written inside CustomAppend() which is called from ProcessBlock() per block, and it writes to the db immediately.
These are two important points, I can see now.

I was puzzled by the fact that there is a loop in Sync(), and another loop is introduced in ProcessBlocks(). But the two complement each other.

Another approach would be to keep the outer loop in Sync() block-by-block, store a CDBBatch variable, process one block, count the blocks in the batch, and flush the batch if batch size is reached (outside of ProcessBlock). This would be slightly less change (no secondary loop), but -- if I'm correct -- the cs_main lock would be still accessed at every block.

... I would argue in favor of ditching the time window in the future, but.. that's not something related to this PR.
Maybe Commit() could be called with the same granularity as batch writes, but that's out of scope here.

@furszy

furszy commented Feb 9, 2026

Copy link
Copy Markdown
Member Author

Another approach would be to keep the outer loop in Sync() block-by-block, store a CDBBatch variable, process one block, count the blocks in the batch, and flush the batch if batch size is reached (outside of ProcessBlock). This would be slightly less change (no secondary loop), but -- if I'm correct -- the cs_main lock would be still accessed at every block.

Yes. That would still lock cs_main on every block, which harms the overall node responsiveness, and it is also not very helpful for the parallelization goal, which requires certain isolation between batches so worker threads can process them concurrently.

furszy added 3 commits March 4, 2026 17:19
No behavior change. This is just for correctness.
No behavior change.

Introduce ProcessBlocks(start, end) to handle a range of blocks
in forward order. Currently used per-block, but this lays the
foundation for future batch processing and parallelization.

This has the nice property of allowing us to collect the block
indexes we are about to process without locking 'cs_main'. Just
by traversing the chain backwards via each block index pprev.
@furszy furszy force-pushed the 2026_index_batch_processing branch from 80aea07 to 5be65db Compare March 4, 2026 20:25
@furszy

furszy commented Mar 4, 2026

Copy link
Copy Markdown
Member Author

Updated per feedback. Thanks @arejula27.
Also rebased on master to pull the CI fixes.

Comment thread src/index/base.cpp
@l0rinc

l0rinc commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

Did another index benchmark for the latest push on an HDD:

image
indexes | i7-hdd | x86_64 | Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz | 8 cores | 62Gi RAM | ext4 | HDD
BEFORE="4c40a923f003420193aa574745f70788bcf35265"; AFTER="5be65dbcdceaafb6f5a0e34c69efc2b3dfcfe27a"; \
DATA_DIR="/mnt/my_storage/BitcoinData"; export DATA_DIR; \
RESULTS_FILE="${DATA_DIR}/index_benchmark_results.txt"; export RESULTS_FILE; \
wait_index() { \
  tail -F ${DATA_DIR}/debug.log 2>/dev/null | grep -q -m1 'index is enabled\|is enabled at height'; \
  sleep 2; \
  killall bitcoind 2>/dev/null || true; \
  wait; \
}; export -f wait_index; \
log_size() { \
  local label="$1"; local index_dir="$2"; \
  local size=$(du -sh "$index_dir" 2>/dev/null | cut -f1); \
  local files=$(find "$index_dir" -type f -name '*.ldb' 2>/dev/null | wc -l); \
  echo "$label: size=$size, ldb_files=$files" | tee -a "$RESULTS_FILE"; \
}; export -f log_size; \
git reset --hard >/dev/null 2>&1 && git clean -fxd >/dev/null 2>&1 && git fetch origin $BEFORE $AFTER >/dev/null 2>&1; \
for c in $BEFORE:build-before $AFTER:build-after; do \
  git checkout ${c%:*} >/dev/null 2>&1 && cmake -B ${c#*:} -G Ninja -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 && ninja -C ${c#*:} bitcoind >/dev/null 2>&1; \
done; \
echo "indexes | $(hostname) | $(uname -m) | $(lscpu | grep 'Model name' | head -1 | cut -d: -f2 | xargs) | $(nproc) cores | $(free -h | awk '/^Mem:/{print $2}') RAM | $(df -T $DATA_DIR | awk 'NR==2{print $2}') | $(lsblk -no ROTA $(df --output=source $DATA_DIR | tail -1) 2>/dev/null | grep -q 0 && echo SSD || echo HDD)" | tee -a "$RESULTS_FILE" && \
for INDEX in txindex blockfilterindex coinstatsindex txospenderindex;; do \
  echo -e "\n--- $INDEX ---" | tee -a "$RESULTS_FILE"; \
  if [ "$INDEX" = "blockfilterindex" ]; then \
    INDEX_DIR="${DATA_DIR}/indexes/blockfilter/basic"; \
  else \
    INDEX_DIR="${DATA_DIR}/indexes/${INDEX}"; \
  fi; \
  export INDEX_DIR; \
  for BUILD in before after; do \
    if [ "$BUILD" = "before" ]; then COMMIT="${BEFORE:0:7}"; else COMMIT="${AFTER:0:7}"; fi; \
    hyperfine --runs 1 --shell bash --sort command \
      --prepare "rm -rf ${DATA_DIR}/indexes/* ${DATA_DIR}/debug.log" \
      --cleanup "log_size '${INDEX} ${BUILD}' '${INDEX_DIR}'" \
      -n "${BUILD} (${COMMIT})" \
      "./build-${BUILD}/bin/bitcoind -datadir=${DATA_DIR} -${INDEX}=1 -connect=0 -printtoconsole=0 & wait_index" \
      2>&1 | tee -a "$RESULTS_FILE"; \
  done; \
done;

--- txindex ---

Benchmark 1: before (4c40a92)
Time (abs ≡): 23757.916 s [User: 13234.906 s, System: 1697.284 s]

Benchmark 1: after (5be65db)
Time (abs ≡): 21134.433 s [User: 12627.245 s, System: 1483.096 s]

--- blockfilterindex ---

Benchmark 1: before (4c40a92)
Time (abs ≡): 14205.566 s [User: 8328.684 s, System: 418.981 s]

Benchmark 1: after (5be65db)
Time (abs ≡): 14046.610 s [User: 8286.885 s, System: 415.464 s]

--- coinstatsindex ---

Benchmark 1: before (4c40a92)
Time (abs ≡): 40075.612 s [User: 34320.885 s, System: 403.669 s]

Benchmark 1: after (5be65db)
Time (abs ≡): 39869.235 s [User: 34307.695 s, System: 398.322 s]

--- txospenderindex ---
Benchmark 1: before (4c40a92)
Time (abs ≡): 36324.897 s [User: 23490.919 s, System: 2227.044 s]

Benchmark 1: after (5be65db)
Time (abs ≡): 26375.223 s [User: 21783.716 s, System: 1690.540 s]

Edit: added txospenderindex

@arejula27

Copy link
Copy Markdown

Did another index benchmark for the latest push on an HDD:

image
indexes | i7-hdd | x86_64 | Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz | 8 cores | 62Gi RAM | ext4 | HDD
BEFORE="4c40a923f003420193aa574745f70788bcf35265"; AFTER="5be65dbcdceaafb6f5a0e34c69efc2b3dfcfe27a"; \
DATA_DIR="/mnt/my_storage/BitcoinData"; export DATA_DIR; \
RESULTS_FILE="${DATA_DIR}/index_benchmark_results.txt"; export RESULTS_FILE; \
wait_index() { \
  tail -F ${DATA_DIR}/debug.log 2>/dev/null | grep -q -m1 'index is enabled\|is enabled at height'; \
  sleep 2; \
  killall bitcoind 2>/dev/null || true; \
  wait; \
}; export -f wait_index; \
log_size() { \
  local label="$1"; local index_dir="$2"; \
  local size=$(du -sh "$index_dir" 2>/dev/null | cut -f1); \
  local files=$(find "$index_dir" -type f -name '*.ldb' 2>/dev/null | wc -l); \
  echo "$label: size=$size, ldb_files=$files" | tee -a "$RESULTS_FILE"; \
}; export -f log_size; \
git reset --hard >/dev/null 2>&1 && git clean -fxd >/dev/null 2>&1 && git fetch origin $BEFORE $AFTER >/dev/null 2>&1; \
for c in $BEFORE:build-before $AFTER:build-after; do \
  git checkout ${c%:*} >/dev/null 2>&1 && cmake -B ${c#*:} -G Ninja -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 && ninja -C ${c#*:} bitcoind >/dev/null 2>&1; \
done; \
echo "indexes | $(hostname) | $(uname -m) | $(lscpu | grep 'Model name' | head -1 | cut -d: -f2 | xargs) | $(nproc) cores | $(free -h | awk '/^Mem:/{print $2}') RAM | $(df -T $DATA_DIR | awk 'NR==2{print $2}') | $(lsblk -no ROTA $(df --output=source $DATA_DIR | tail -1) 2>/dev/null | grep -q 0 && echo SSD || echo HDD)" | tee -a "$RESULTS_FILE" && \
for INDEX in txindex blockfilterindex coinstatsindex; do \
  echo -e "\n--- $INDEX ---" | tee -a "$RESULTS_FILE"; \
  if [ "$INDEX" = "blockfilterindex" ]; then \
    INDEX_DIR="${DATA_DIR}/indexes/blockfilter/basic"; \
  else \
    INDEX_DIR="${DATA_DIR}/indexes/${INDEX}"; \
  fi; \
  export INDEX_DIR; \
  for BUILD in before after; do \
    if [ "$BUILD" = "before" ]; then COMMIT="${BEFORE:0:7}"; else COMMIT="${AFTER:0:7}"; fi; \
    hyperfine --runs 1 --shell bash --sort command \
      --prepare "rm -rf ${DATA_DIR}/indexes/* ${DATA_DIR}/debug.log" \
      --cleanup "log_size '${INDEX} ${BUILD}' '${INDEX_DIR}'" \
      -n "${BUILD} (${COMMIT})" \
      "./build-${BUILD}/bin/bitcoind -datadir=${DATA_DIR} -${INDEX}=1 -connect=0 -printtoconsole=0 & wait_index" \
      2>&1 | tee -a "$RESULTS_FILE"; \
  done; \
done;

--- txindex ---

Benchmark 1: before (4c40a92)
Time (abs ≡): 23757.916 s [User: 13234.906 s, System: 1697.284 s]

Benchmark 1: after (5be65db)
Time (abs ≡): 21134.433 s [User: 12627.245 s, System: 1483.096 s]

--- blockfilterindex ---

Benchmark 1: before (4c40a92)
Time (abs ≡): 14205.566 s [User: 8328.684 s, System: 418.981 s]

Benchmark 1: after (5be65db)
Time (abs ≡): 14046.610 s [User: 8286.885 s, System: 415.464 s]

--- coinstatsindex ---

Benchmark 1: before (4c40a92)
Time (abs ≡): 40075.612 s [User: 34320.885 s, System: 403.669 s]

Benchmark 1: after (5be65db)
Time (abs ≡): 39869.235 s [User: 34307.695 s, System: 398.322 s]

It looks like the improvement is not very noticeable for two of the indexes. It might be interesting to try increasing the batch size. Do you have any suggestions on what could be causing this?

@sedited

sedited commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

It looks like the improvement is not very noticeable for two of the indexes. It might be interesting to try increasing the batch size. Do you have any suggestions on what could be causing this?

I'm not sure this is really surprising given the difference in the amount of data written to the latter two. I would expect a jump more similar to the txindex for the txospenderindex.

What is the expectation on the increase of memory usage here? I tried profiling it with massif, but usage when running with this patch seemed a bit erratic, which I guess is still an indication that it is likely to be higher. Can we really batch operations in this way without putting more memory pressure on the OS?

@furszy

furszy commented Mar 8, 2026

Copy link
Copy Markdown
Member Author

Can we really batch operations in this way without putting more memory pressure on the OS?

The batch size is intentionally small. I was very conservative with the chosen value to avoid having to worry about it here. The goal of this PR is to land the structural improvements, with a net positive: less cs_main locking, merging prerequisites for the parallelization goal, and the last discovery of reducing the number of created LBD files.

It's expected that any batching implementation adds some memory overhead, but that shouldn't be a problem, it pays off in many other areas, not only with a faster sync promise. And, if we ever wanted to, we could add a config flag to disable batching by setting the batch size to 1. But realistically speaking, I don't think anyone running indexes would want them to take hours to sync while locking cs_main and slowing down the whole node. People who run indexes want them to sync-up quickly so they can start using them.

Also, an important point. All these changes only matter during the index initial sync period. Once the index is synced, they are no longer relevant.

We can benchmark this on small devices like the Pi 4 to see the benefits more clearly, but I honestly wouldn't spend too much time on it. If we agree this PR lands structural, scalable improvements with many benefits and no noticeable downside, and also lets us move forward with the major parallelization speedup path, it seems reasonable to just move forward. This also has a good number of tests we currently lack from.

furszy and others added 7 commits March 8, 2026 15:09
This is a refactoring that makes NextSyncBlock easily adaptable to
return block ranges instead of single blocks in the subsequent commit.

It also adds a fast-path that avoids calling 'FindFork()' and 'Next()'
when the index is synced.

Co-authored-by: Hao Xu <hao.xu@linux.dev>
Set end of the window of blocks an index will process at time.
Stopping at either the configured window size or the chain tip.

This is the first step toward batch and parallel processing.
These block windows become units of work and live in memory until
flushed to disk in one go.

Since each index has a different data size, the range size
is configurable so it can be tuned considering memory
consumption.
Pass CDBBatch to subclasses so writes can be accumulated
and committed together instead of flushed per block

Note:
Batch writes were intentionally not tied to the existing Commit() method.
The rationale is to bound memory consumption as batches accumulate in RAM
until flushed to disk. This leaves two separate workflows with different
purposes:

1) Batch writes flushes data for a block range atomically: either all
blocks in the batch land in LevelDB or none do.

2) Commit() is about progress checkpointing: it writes the best block
locator alongside any other index state position (e.g. last written
file for the block filter index - which requires an fsync call), so the
node always has a consistent recovery point after an interruption or crash.

Reprocessing data after a crash is not really a problem because we would
just overwrite the existing one.
Verifies the index persists the last processed index when interrupted.
@furszy furszy force-pushed the 2026_index_batch_processing branch from 5be65db to 1497086 Compare March 8, 2026 18:10

@l0rinc l0rinc 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.

Concept ACK on the goal: batching is the right direction for reducing write amplification, cs_main contention, and LDB file proliferation.

However, I think it's important to stress my concern that this change is more complex than it's being treated as.
It alters the invariants every index subclass relies on: in-memory state can now advance ahead of what's persisted, and the interrupt/shutdown paths haven't been fully thought through for each subclass.
The most serious issue seems to me that Commit() during interrupt writes advanced m_muhash alongside a stale locator, which causes CoinStatsIndex::CustomInit to reject the index as corrupted on restart. blockfilterindex has a milder variant (orphaned flat file data). I wonder if fuzzing could catch these.

More generally, several "no behavior change" refactoring commits don't explain why the restructuring is safe, and memory concerns are brushed away with "shouldn't be a problem".
I also left a ton of comments about the ProcessBlocks loop needing tighter bounds and the shutdown test only exercising a no-op BaseIndex subclass. It doesn't catch the actual subclass corruption paths.
Some of the earlier commits (e.g. the iterator ordering fix) could land as standalone PRs to reduce the review surface here.

See inline comments for details on these and other issues (interrupt handler scoping, NextSyncBlock invariants, loop safety, stale comments, etc.).

Note

in the meantime, I finished the HDD benchmarks with txospenderindex measurements, see #34489 (comment).

Comment thread src/index/base.cpp Outdated
}
pindex = pindex_next;

if (!ProcessBlock(pindex_next)) return; // error logged internally

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.

95fc91f index: sync, update iterator only after processing block:

We're changing behavior in a commit that claims it's not a behavior change, without adding context for why this change was needed or how to validate that it is correct. Given that it wasn't obvious to the original authors, an explanation is needed. Also, No test was added to exercise this behavior, to explain exactly why it was necessary, and make sure it doesn't happen again.

It also seems independent enough to be pushed in a separate PR, with a test documenting the current behavior in the first commit and, in the next commit, a fix and a test update that reflects the new behavior.

@furszy furszy Mar 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

95fc91f index: sync, update iterator only after processing block:

We're changing behavior in a commit that claims it's not a behavior change, without adding context for why this change was needed or how to validate that it is correct. Given that it wasn't obvious to the original authors, an explanation is needed. Also, No test was added to exercise this behavior, to explain exactly why it was necessary, and make sure it doesn't happen again.

It also seems independent enough to be pushed in a separate PR, with a test documenting the current behavior in the first commit and, in the next commit, a fix and a test update that reflects the new behavior.

I'm puzzled here. You are claiming this is a behavior change without explaining how, and based on that, you write a strong affirmation for other reviewers rather than engaging with me about what you think this change introduces. That is not something I can act on, and it risks misleading other reviewers.

This is not a behavior change, it is a correctness change. The loop iterator that tracks the last successfully processed block was modified to be updated after processing the block, not before. The reordering of pindex = pindex_next to after the ProcessBlock call has zero effects on any execution path, because we immediately return if block processing fails, and at that point pindex is never read again. If you believe otherwise, please point to the specific code path you think is affected. This change was made to prepare the ground for the batch end block set, which is the actual focus of this PR. Splitting it out into a separate PR would add unnecessary overhead for a one-line reordering with no behavioral effect.

On the commit message: if the description was not clear enough, just say so. I would have updated it. Engaging directly about what is unclear is far more constructive than making assertions aimed at other reviewers.

On tone: "it wasn't obvious to the original authors" is truly unhelpful. It does not engage with the code, it does not help improve anything, and it makes it genuinely harder to engage with your other comments in good faith, even the ones that may be perfectly valid. The purpose of review is to work with the author to improve the code, not to editorialize for other readers.

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.

rather than engaging with me about what you think this change introduces

Not sure what you mean, I commented on your PR, meant it for you to read it.

I would have updated it.

I'm not sure about the past tense, I reviewed it because I'm expecting further changes, especially because of the bugs introduced here.

On tone: "it wasn't obvious to the original authors" is truly unhelpful.

How so? I don't know or care who wrote the original code, they had an intention here, if we're changing it, it needs explanation and preferably tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm not sure about the past tense, I reviewed it because I'm expecting further changes, especially because of the bugs introduced here.

Please stick to the topic in question. I have not yet read your other comments on this PR. This is the first one, and it makes a strong claim without a proper explanation to back it up.

Can you please point to the specific code path where this commit introduces a behavior change? Thanks.

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 not yet read your other comments on this PR

That explains the confusion.

Can you please point to the specific code path where this commit introduces a behavior change

Please read the rest of my comments, that's why I posted them...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems obvious to me that 95fc91f cannot change behavior; it's only swapping the change of local variable with a return statement, at which point the local variable disappears anyway. So I agree with @furszy that I don't understand what the comment in 95fc91f#r2905123979 is about, independently from all other comments left on this PR.

@l0rinc l0rinc Mar 16, 2026

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 commit does not explain why the swap is needed. That only becomes clear later, when a non-adjacent commit relies on pindex meaning "last successfully processed block" for the new interrupt path, see #34489 (comment).

So the issue is not this line movement in isolation, but that it is a preparatory change whose purpose is only revealed several commits later, in a commit that changes behavior while claiming to be a refactor. I think those commits should be squashed and the behavior change either fixed or documented.

Comment thread src/index/base.cpp
}
}

if (m_interrupt) {

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.

0cbe84d index: refactor, decouple interruption from sync loop:

The commit claims there's no behavior change, but it doesn't explain why the move is safe. Given all the internal returns and breaks it's not self-evident.


It seems to me that previously a synced, and later interrupted, path could hit

LogInfo("%s is enabled at height %d", GetName(), pindex->nHeight);

, but after the change it hits

LogInfo("%s: m_interrupt set; exiting ThreadSync", GetName());

and returns.

I'm not sure there is any other change, but I would appreciate a commit message explanation, and maybe a test exercising this path, preferably a fuzz test to cover all these weird combinations.

Comment thread src/index/base.cpp

bool BaseIndex::ProcessBlocks(const CBlockIndex* start, const CBlockIndex* end)
{
// Collect all block indexes from [end...start] in order

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.

0336c61 index: add method to process block ranges:

nit: for consistency we could use the same comment style as below:

Suggested change
// Collect all block indexes from [end...start] in order
// Collect all block indexes from end to start

Comment thread src/index/base.cpp
// Collect all block indexes from [end...start] in order
std::vector<const CBlockIndex*> ordered_blocks;
ordered_blocks.reserve(end->nHeight - start->nHeight + 1);
for (const CBlockIndex* block = end; block && start->pprev != block; block = block->pprev) {

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.

0336c61 index: add method to process block ranges:

When can block be nullptr here? Wouldn't it be a fatal error if we walk past genesis? And what if the caller mixes up begin and end? Or if start and end have valid heights but are on different forks.

Given how intertwined the code is here, a serious mess-up could cause an infinite loop and would be really hard to trace back to the source.

I would probably sleep better if we asserted that end->nHeight >= start->nHeight and used a bounded for loop here instead.

const int range_size{end->nHeight - start->nHeight + 1};
Assert(range_size > 0);

// Collect all block indexes from end to start
std::vector<const CBlockIndex*> ordered_blocks;
{
    ordered_blocks.reserve(range_size);
    CBlockIndex* it = end;
    for (int i{0}; i < range_size; ++i) {
        ordered_blocks.emplace_back(Assert(it));
        it = it->pprev;
    }
    Assert(it == start->pprev);
}

Comment thread src/index/base.cpp

// And process blocks in forward order: from start to end
for (auto it = ordered_blocks.rbegin(); it != ordered_blocks.rend(); ++it) {
if (m_interrupt) return false;

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.

0336c61 index: add method to process block ranges:

The method comment states:

Returns false on unrecoverable failure or during interruption

But if the interruption happens before we call the first ProcessBlock, we should be safe since no state was changed - i.e. nothing to recover from.


// Verifies that the index persists its sync progress when interrupted during initial sync.
// The index should resume from the last processed batch rather than restarting from genesis.
BOOST_FIXTURE_TEST_CASE(shutdown_during_initial_sync, BuildChainTestingSetup)

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.

1497086 test: Add coverage for index locator persistence during shutdown:

This should have probably caught the partial write regression.
Can we extend IndexCommitStateSim to implement CustomCommit as well?

index.Stop();
}

// Ensure the initial sync batch window behaves as expected.

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.

7c82cc8 test: index, add initial sync batch writes coverage:

Adding tests at the very end doesn't help document the progression of the changes introduced in this PR, commit by commit.
We could add this test as a first commit:

// Ensure initial sync can be restarted cleanly without overwriting earlier
// results. Tests sync from genesis and from a higher block to mimic a restart.
BOOST_FIXTURE_TEST_CASE(initial_sync_restart, BuildChainTestingSetup)

and later, in the batch size commit, just add filter_index.SetProcessingBatchSize(BATCH_SIZE); to the test to prove that it still passes. That way, we are asserting that the previous behavior is retained, not just that the new behavior is covered.

// Tests that indexes can complete its initial sync even if a reorg occurs mid-sync.
// The index is paused at a specific block while a fork is introduced a few blocks before the tip.
// Once unblocked, the index should continue syncing and correctly reach the new chain tip.
BOOST_FIXTURE_TEST_CASE(initial_sync_reorg, BuildChainTestingSetup)

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.

28d8b04 test: index, add coverage for initial sync reorgs:

This test almost completely passes on master - we should add as much coverage before the refactor.
But we need to make it more realistic, likely by overriding CustomAppend as well.

Comment on lines +522 to +524
constexpr int SHUTDOWN_HEIGHT = 45;
constexpr int BATCH_SIZE = 10;
constexpr int EXPECTED_LAST_SYNCED_BLOCK = 39;

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.

1497086 test: Add coverage for index locator persistence during shutdown:

This passes before the change with:

    // The index will be interrupted while processing block 45. Since sync is
    // still per-block here, the last persisted block is expected to be 45.
    constexpr int SHUTDOWN_HEIGHT = 45;
    constexpr int EXPECTED_LAST_SYNCED_BLOCK = 45;

(and no SetProcessingBatchSize, of course)

bool BlockFilterIndex::Write(const BlockFilter& filter, uint32_t block_height, const uint256& filter_header)
bool BlockFilterIndex::Write(CDBBatch& batch, const BlockFilter& filter, uint32_t block_height, const uint256& filter_header)
{
size_t bytes_written = WriteFilterToDisk(m_next_filter_pos, filter);

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 seem to be writing to disk to eagerly here, this also is misaligned with the batching.

@furszy furszy left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Clarifying one of the comments.

Comment thread src/index/base.cpp Outdated
}
pindex = pindex_next;

if (!ProcessBlock(pindex_next)) return; // error logged internally

@furszy furszy Mar 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

95fc91f index: sync, update iterator only after processing block:

We're changing behavior in a commit that claims it's not a behavior change, without adding context for why this change was needed or how to validate that it is correct. Given that it wasn't obvious to the original authors, an explanation is needed. Also, No test was added to exercise this behavior, to explain exactly why it was necessary, and make sure it doesn't happen again.

It also seems independent enough to be pushed in a separate PR, with a test documenting the current behavior in the first commit and, in the next commit, a fix and a test update that reflects the new behavior.

I'm puzzled here. You are claiming this is a behavior change without explaining how, and based on that, you write a strong affirmation for other reviewers rather than engaging with me about what you think this change introduces. That is not something I can act on, and it risks misleading other reviewers.

This is not a behavior change, it is a correctness change. The loop iterator that tracks the last successfully processed block was modified to be updated after processing the block, not before. The reordering of pindex = pindex_next to after the ProcessBlock call has zero effects on any execution path, because we immediately return if block processing fails, and at that point pindex is never read again. If you believe otherwise, please point to the specific code path you think is affected. This change was made to prepare the ground for the batch end block set, which is the actual focus of this PR. Splitting it out into a separate PR would add unnecessary overhead for a one-line reordering with no behavioral effect.

On the commit message: if the description was not clear enough, just say so. I would have updated it. Engaging directly about what is unclear is far more constructive than making assertions aimed at other reviewers.

On tone: "it wasn't obvious to the original authors" is truly unhelpful. It does not engage with the code, it does not help improve anything, and it makes it genuinely harder to engage with your other comments in good faith, even the ones that may be perfectly valid. The purpose of review is to work with the author to improve the code, not to editorialize for other readers.

@arejula27

Copy link
Copy Markdown

I was thinking to buy and external disk (HDD) and try to benchmark the memory impact, would it be interesting or having the blockchain and the index on different disks make the test not useful?

@l0rinc

l0rinc commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

having the blockchain and the index on different disks make the test not useful?

for memory profiling it shouldn't matter - just know that these measurements are notoriously slow

@arejula27

arejula27 commented Mar 29, 2026

Copy link
Copy Markdown

@furszy Do you think #34897 has any implications on this PR and #26966? If indexes can only commit up to the chainstate's last flushed block, the batching and parallelisation speedups might be limited by the flush interval (~50-70 min) rather than by index processing itself. Would it make sense to keep the current per-block behaviour during IBD (where the index is catching up and flush lag matters most) and use batching only for fully synced nodes processing new blocks using new methods?? Another option could be each index sets its own batch size. For example, blockfilterindex (required during IBD) could use a batch size of 1 to keep commit granularity tight, while optional indexes like coinstatsindex or txindex could use larger batches since their commit timing is less critical.

PS: I got my chain synced, will try to do the memory benchmark this week

@DrahtBot

Copy link
Copy Markdown
Contributor

🐙 This pull request conflicts with the target branch and needs rebase.

@polespinasa polespinasa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

concept ACK

Just did a swift code review for the Spanish PR review club :)

I'm 0 familiar with this part of the codebase, but left some comments that ¿might? be useful.

I found the first two commits a bit difficult to understand because, although it is obvious that it is not a behavior change, there's no explanation in the commit message on why those changes are needed. I think a small sentence saying why that helps is useful :)

Comment thread src/index/base.cpp
const CBlockIndex* pindex_next = WITH_LOCK(cs_main, return NextSyncBlock(pindex, m_chainstate->m_chain));
// If pindex_next is null, it means pindex is the chain tip, so
auto block_range = WITH_LOCK(cs_main, return NextSyncRange(pindex, m_chainstate->m_chain));
// If range.first is null, it means pindex is the chain tip, so

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

in 64578a7 index: make NextSyncBlock return block ranges
Probably worth not using auto here so it's visible that it can not have a value and that means it is the tip.
Also I think you meant if block_range is not set? not range.first

Comment thread src/index/base.cpp Outdated

// For now, process a single block at time
if (!ProcessBlocks(/*start=*/pindex_next, /*end=*/pindex_next)) {
if (!ProcessBlocks(block_range->first, block_range->last)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

in 64578a7 index: make NextSyncBlock return block ranges
I guess ProcessBlocks can just take the new BlockRange struct.

Comment thread src/index/base.h
struct CBlockLocator;

/** Range of blocks to process in batches */
static constexpr int INDEX_BATCH_SIZE = 500;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

in 1628b10 index: add block range computation
why 500? is it an arbitrary number?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think that was decided as an arbitrary small number just for the first approach, then each index will have different batch sizes based on the benchmarks

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.