Skip to content

Continue an interrupted -reindex from the block files on any start (#906) - #915

Merged
bkeroack merged 2 commits into
release/0.6from
fix/reindex-resume
Oct 7, 2026
Merged

bkeroack merged 2 commits into
release/0.6from
fix/reindex-resume

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #906. Stacked on #914 (#907): merge #914 first, then retarget this PR to release/0.6.

The problem

0.6.0 refused every start after an interrupted full -reindex except one with --reindex, and that start wiped the database and replayed from genesis. A mainnet replay takes days, so every stop, reboot or settings change during one threw the work away. A package that passes --reindex once, as StartOS's migration from Bitcoin Core does, was left with a node that would not start.

The change

You chose this behaviour: any start that finds an unfinished full reindex continues it, whatever the flags.

How it continues. resume_reindex_from_flat_files scans the block files and plans the chain exactly as before. Before connecting anything, check_resumable_prefix checks every block the interrupted run stored against the scan:

  • the stored tip is the planned block at its height;
  • each entry at or below it is Valid at its planned height, with the header the files hold, at the file position the scan found;
  • each entry's chainwork is the sum of its ancestors' header work.

What the continued replay reads from those entries (parent links, chainwork, the timestamps behind median time past) is then backed by the block files, as in a replay from genesis. It then connects the rest. Nothing is downloaded and no block file is written. The check costs one index read per stored block.

When it can't continue. The rebuild marker gains resumable. It is set once the wipe has finished and genesis is indexed and flushed durably, which is just before the scan. Three cases run the reindex again from genesis instead, still on any start:

  • a marker without the flag: one 0.6.0 wrote, or one written before its wipe had finished;
  • a marker at another chainstate schema;
  • a stored chain that fails the check. satd exits 1 with ReindexCannotResume, having changed nothing, and clears resumable; the next start runs from genesis.

This differs from Core in one case. Core also continues an interrupted reindex on a plain start, but -reindex passed again makes Core wipe and start over. Here it continues: you chose that so a package that keeps passing --reindex, or an operator following 0.6.0's "restart with --reindex" message, doesn't lose the replay. To start over, remove the chainstate directory.

Smaller pieces:

Tests

  • Unit, node:
    • a stopped reindex continues onto the uninterrupted run's UTXO set;
    • a stored entry at the wrong position fails ReindexCannotResume and connects nothing;
    • a fresh reindex refuses a stored chain;
    • a continued run past -stopatheight connects nothing;
    • a 0.6.0 marker reads as not resumable.
  • Unit, satd: the decision matrix across flag combinations. A resumable marker at the current schema continues; a 0.6.0 marker or one at another schema restarts.
  • Integration (regtest):
    • a_killed_full_reindex_continues_from_the_block_files: SIGKILL after the durable flush at height 1000.
    • a_full_reindex_killed_before_it_connects_anything_continues: SIGKILL during the scan. If the kill lands after the first flush (a stalled test thread), it retries on a fresh copy, up to three times.
    • In both, a plain start must log that it continues, clear nothing, write no block file (names and sizes compared), and end on the fixture's UTXO set.
    • a_full_reindex_left_by_0_6_0_runs_again_on_any_start replaces the 0.6.0 refusal test.
    • Stop a startup block replay cleanly on SIGTERM, also as PID 1 (#907) #914's SIGTERM tests now finish with a plain start that continues.
  • Perturbations:
    • Routing ContinueFullReindex to a restart fails the kill test.
    • Dropping the position comparison fails the unit refusal test.
    • The prefix is skipped twice over: the path is sliced, and its headers are dropped from the scan's map for memory. Undoing both fails the continue test with BadPrevBlock; either alone still skips it.
    • Dropping the durable flush before resumable is set does not fail a kill test. Genesis reaches RocksDB through the cache's pass-through with the WAL on, which a kill doesn't lose, so the flush only adds power-cut safety. The test notes say so.

Local gates

Since opening

Not done

  • A continued reindex reads every block file in full again before it connects anything: about 700 GB on mainnet, more than an hour on a hard drive. Filed as A continued -reindex reads every block file in full before it connects anything #916, to fix after these two.

  • No integration test drives the ReindexCannotResume exit path end to end. That needs a stored chain the files contradict, which the regtest harness can't produce without writing RocksDB rows by hand. The check itself is unit-tested, and the exit path is a few lines in main.rs.

  • Reindex throughput (Start9's other point) is untouched.

🤖 Generated with Claude Code

@bkeroack
bkeroack force-pushed the fix/reindex-resume branch from 16ce119 to 860b60e Compare October 6, 2026 18:53
@bkeroack
bkeroack force-pushed the fix/replay-stop-on-sigterm branch from 6ce1f3f to f3e81d7 Compare October 6, 2026 19:35
@bkeroack
bkeroack force-pushed the fix/reindex-resume branch from 860b60e to 89a6a09 Compare October 6, 2026 19:35
@bkeroack
bkeroack force-pushed the fix/replay-stop-on-sigterm branch from f3e81d7 to 6b695c1 Compare October 6, 2026 22:44
@bkeroack
bkeroack changed the base branch from fix/replay-stop-on-sigterm to release/0.6 October 6, 2026 23:31
bkeroack and others added 2 commits October 6, 2026 17:31
)

0.6.0 refused every start after an interrupted full -reindex but one with
--reindex, which then wiped the database and replayed from genesis. A
mainnet replay takes days, so every stop, reboot or settings change during
one threw it away, and a package that passes --reindex once (StartOS's
migration from Bitcoin Core) was left with a node that would not start.

Now any start that finds an unfinished full reindex continues it, whatever
the flags. The flat-file replay scans the block files and plans the chain
as before. It then checks every block the interrupted run had stored
against the scan, before connecting the rest:

- the stored tip must be the planned block at its height;
- each entry at or below it must be indexed at its planned height as
  Valid, with the header the files hold, at the position the scan found;
- each entry's chainwork must equal the sum of its ancestors' header work.

What the continued replay reads from those entries (parent links,
chainwork, the timestamps behind median time past) is then backed by the
block files, as in a replay from genesis. Nothing is downloaded and no
block file is written.

A reindex is continued only when its marker says so. The rebuild marker
gains `resumable`, set once the wipe has finished and genesis is indexed
and durable, just before the scan. A marker without it, from satd 0.6.0
or written before its wipe had finished, or one from another chainstate
schema, runs the reindex again from genesis, still on any start. A
stored chain that fails the check exits with ReindexCannotResume, having
changed nothing; satd clears `resumable`, and the next start runs from
genesis.

A fresh reindex now refuses a chainstate past genesis. A continued run
that had already reached -stopatheight connects nothing more, and indexes
no side chain. A stop requested by #907's signal handling is continued
by the next start like any other interruption.

Tests: unit tests continue a stopped reindex onto the uninterrupted UTXO
set, refuse a stored entry the files do not back, refuse a fresh reindex
over a stored chain, handle a continued run past -stopatheight, and read
a 0.6.0 marker as not resumable. Integration tests SIGKILL a reindex after
its durable flush at height 1000, and during its scan, then check that a
plain start continues: it logs so, clears nothing, writes no block file,
and ends on the fixture's UTXO set. A 0.6.0 marker runs again from
genesis on a plain start. #907's SIGTERM tests now finish with a plain
start that continues.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dex (#906)

The check before a continued reindex now also requires every height row
above the stored tip to name the planned block at its height. A connect
that moves no coins bypasses the coin cache's buffer, so a kill can leave
its rows durable above the tip; over the same block files the replay
writes them again, but a row naming another block would outlive it.

The check also polls for a stop every 1000 entries. On mainnet it reads
some 900k index entries, and a stop that waited for it could overrun
-maxshutdownsecs and exit 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack force-pushed the fix/reindex-resume branch from e051c34 to eabd86e Compare October 6, 2026 23:31
@bkeroack
bkeroack merged commit a0b69c6 into release/0.6 Oct 7, 2026
37 checks passed
@bkeroack
bkeroack deleted the fix/reindex-resume branch October 7, 2026 00:12
bkeroack added a commit that referenced this pull request Oct 7, 2026
) (#915)

* Continue an interrupted -reindex from the block files on any start (#906)

0.6.0 refused every start after an interrupted full -reindex but one with
--reindex, which then wiped the database and replayed from genesis. A
mainnet replay takes days, so every stop, reboot or settings change during
one threw it away, and a package that passes --reindex once (StartOS's
migration from Bitcoin Core) was left with a node that would not start.

Now any start that finds an unfinished full reindex continues it, whatever
the flags. The flat-file replay scans the block files and plans the chain
as before. It then checks every block the interrupted run had stored
against the scan, before connecting the rest:

- the stored tip must be the planned block at its height;
- each entry at or below it must be indexed at its planned height as
  Valid, with the header the files hold, at the position the scan found;
- each entry's chainwork must equal the sum of its ancestors' header work.

What the continued replay reads from those entries (parent links,
chainwork, the timestamps behind median time past) is then backed by the
block files, as in a replay from genesis. Nothing is downloaded and no
block file is written.

A reindex is continued only when its marker says so. The rebuild marker
gains `resumable`, set once the wipe has finished and genesis is indexed
and durable, just before the scan. A marker without it, from satd 0.6.0
or written before its wipe had finished, or one from another chainstate
schema, runs the reindex again from genesis, still on any start. A
stored chain that fails the check exits with ReindexCannotResume, having
changed nothing; satd clears `resumable`, and the next start runs from
genesis.

A fresh reindex now refuses a chainstate past genesis. A continued run
that had already reached -stopatheight connects nothing more, and indexes
no side chain. A stop requested by #907's signal handling is continued
by the next start like any other interruption.

Tests: unit tests continue a stopped reindex onto the uninterrupted UTXO
set, refuse a stored entry the files do not back, refuse a fresh reindex
over a stored chain, handle a continued run past -stopatheight, and read
a 0.6.0 marker as not resumable. Integration tests SIGKILL a reindex after
its durable flush at height 1000, and during its scan, then check that a
plain start continues: it logs so, clears nothing, writes no block file,
and ends on the fixture's UTXO set. A 0.6.0 marker runs again from
genesis on a plain start. #907's SIGTERM tests now finish with a plain
start that continues.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Check rows above the tip and poll for a stop before continuing a reindex (#906)

The check before a continued reindex now also requires every height row
above the stored tip to name the planned block at its height. A connect
that moves no coins bypasses the coin cache's buffer, so a kill can leave
its rows durable above the tip; over the same block files the replay
writes them again, but a row naming another block would outlive it.

The check also polls for a stop every 1000 entries. On mainnet it reads
some 900k index entries, and a stop that waited for it could overrun
-maxshutdownsecs and exit 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit a0b69c6)
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.

1 participant