Make ExtraIndexer checkpoints reorg-safe - #2465
Conversation
jozanek
left a comment
There was a problem hiding this comment.
Verified this fixes the catch-up-stall finding from #2293/#2416 — root cause removed (header selection now follows the best full chain), plus generation-guarded retries and self-driven reconciliation, with the stall scenarios regression-tested. Findings: 2 MINOR, 3 NIT inline; no protocol/consensus concerns.
|
|
||
| protected def historyStorage: HistoryStorage = _history.historyStorage | ||
|
|
||
| protected def fullChainHeaderAtHeight(height: Int): Option[Header] = { |
There was a problem hiding this comment.
MINOR fullChainHeaderAtHeight re-derives chainStatusKey + BestChainMarker inline even though FullBlockProcessor.isInBestFullChain already encapsulates this check, and the ErgoHistory startup block similarly hand-rolls the validity-key hash that HeadersProcessor.validityKey derives. That's three copies of history's storage layout that can silently drift apart if the encoding ever changes. Suggestion: expose static key-derivation/accessor helpers (e.g. lift isInBestFullChain so the indexer and the pre-construction startup check can both reuse it).
There was a problem hiding this comment.
Addressed the best-full-chain half in 71630c3: FullBlockProcessor now owns a package-scoped storage helper, the existing instance method delegates to it, and ExtraIndexer reuses it. I am leaving the pre-construction validity-key lift for a focused cleanup, since it requires changing the HeadersProcessor companion surface rather than this indexer fix.
| * Current newest database schema version. Used to force extra database resync. | ||
| */ | ||
| val NewestVersion: Int = 6 | ||
| val NewestVersion: Int = 7 |
There was a problem hiding this comment.
MINOR Two deliberate operational impacts (both documented in the PR body) should reach the operator-facing release notes: every extraIndex = true node performs a one-time full extra-index rebuild on upgrade to schema 7 (hours on mainnet), and a rollback write failure now shuts down the whole node fail-closed, with a rebuild on restart. Explorer/wallet operators will want to plan for both.
There was a problem hiding this comment.
Agreed. @kushti_ru, please carry these two points into the v6.0.4 release notes: existing non-empty schema-6 extra indexes are reset and rebuilt once on schema 7; and a rollback persistence failure now shuts the node down fail-closed, with startup validation/rebuild on restart. Rebuild duration depends on database size and hardware; this PR does not benchmark it.
| @@ -150,16 +165,46 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor | |||
|
|
|||
| def insertExtra(indexesToInsert: Array[(Array[Byte], Array[Byte])], | |||
There was a problem hiding this comment.
NIT The log-and-swallow insertExtra/removeExtra wrappers now have zero production callers — everything migrated to the Try variants. Consider deprecating or removing them so new call sites can't silently lose write errors.
There was a problem hiding this comment.
Confirmed: there are no in-tree callers left. I am retaining the public wrappers in this maintenance PR to avoid an unannounced source/binary compatibility change. Deprecation can be handled in a cleanup follow-up, with removal reserved for an appropriate breaking release.
| * Base trait for extra indexer actor and its test. | ||
| */ | ||
| trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { | ||
| trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { |
There was a problem hiding this comment.
NIT ExtraIndexer.scala is now 853 lines, over the repo's 800-line guideline. The rollback/reconcile logic added here is fairly self-contained — a candidate to split into its own file.
There was a problem hiding this comment.
Agreed on the guideline breach. I am deferring the split to a dedicated cleanup PR: rollback/reconciliation is coupled to actor state, timers, stash, and self-messages, so extracting it here would widen access boundaries and the review surface of the correctness fix.
| test.lock.unlock() | ||
| } | ||
|
|
||
| private def cacheBlockTransactions(height: Int, transactions: BlockTransactions): Unit = { |
There was a problem hiding this comment.
NIT cacheBlockTransactions reaches the private blockCache via reflection on the scalac-mangled name ($$blockCache) — this breaks on a rename or a Scala-version mangling change. Making the cache protected (or adding an explicit test seam like the other overrides in this actor) would be sturdier.
There was a problem hiding this comment.
Fixed in 71630c3. The cache remains private; a private[extra] final write seam is used by production prefetch and the same-package test actor, so the reflection and scalac-mangled-name dependency are gone.
| _history.headerIdsAtHeight(height) | ||
| .find { id => | ||
| FullBlockProcessor.isInBestFullChain(historyStorage, id) && | ||
| _history.isSemanticallyValid(id) == ModifierSemanticValidity.Valid |
There was a problem hiding this comment.
the second check looks excessive (if block section with identifier id in the best chain , it is semantically valid)
There was a problem hiding this comment.
I traced this before removing it and found a pre-validation window. VerifyADHistorySpecification shows that bestFullBlockOpt can already select a newly stored block while its header and sections are still Unknown, before reportModifierIsValid. typedModifierById filters only Invalid, and ExtraIndexer catch-up can run from startup/retry independently of FullBlockApplied. I therefore kept the explicit Valid check to avoid indexing an Unknown best-chain block.
| unstashAll() | ||
| case Failure(error) => | ||
| log.error(s"Failed to roll back extra indexes to ${targetHeader.height}; shutting down so startup can rebuild", error) | ||
| requestShutdown() |
There was a problem hiding this comment.
EXtra indexer should not kill the node in any case
There was a problem hiding this comment.
Fixed in 98547b5. A rollback persistence failure now stops only the ExtraIndexer actor via context.stop(self); it no longer shuts down the ActorSystem or node. The durable rollback marker remains for startup rebuild. The regression watches a dedicated actor, proves that actor terminates, proves the ActorSystem stays alive, and preserves the interrupted-marker assertion. Focused suite: 23/23 green.
| } | ||
|
|
||
| protected def blockTransactionsForHeader(header: Header): Option[BlockTransactions] = { | ||
| history.typedModifierById[BlockTransactions](header.transactionsId).filter(_.headerId == header.id) |
|
Updated in a40c206. I removed the extra |
Summary
Why
#2442 made the running indexer track an exact header, but restart still reconstructed that identity from the current best header at the stored height. During a fork, the persisted rows could represent branch A while the reloaded state claimed branch B. Replacement block events could then be applied over the wrong rows before the delayed rollback notification.
This change persists provenance instead of inferring it, validates checkpoint shape and terminal mappings on startup, and drives recovery from the current valid full-block chain. Missing block sections and transient header/full-block disagreement are deferred and retried rather than being recorded as caught up.
Validation
ExtraIndexerSpecification: 23/23HistoryStorageSpec: 4/4LDBKVStoreSpec: 3/3The regressions cover restart onto a competing branch, replacement events before rollback, buffered catch-up followed by reorg, missing transaction sections, malformed/interrupted checkpoints, rollback write failure, cache write failures and retries, an in-flight cache-miss/write race, and production actor startup through the event stream.
Operational notes