fix(indexer): degrade-not-halt + self-repair the secondary index (network-wide db-corruption halt) - #147
Conversation
The indexer hard-halts the WHOLE subsystem (reason=DbCorruption) when a single sign-flip on a DERIVED secondary index (template/token box-segment) finds no entry to flip. This deterministically freezes every node's /blockchain/* the moment an ancient box (e.g. created 2022) is first spent at its exact storage-rent eligibility height (creationHeight + 1_051_200): its +gi creation entry is missing from the heavily-shared template segment (present in the per-address segment), so the template spend-flip halts the whole indexer. It reproduces identically on every node (fixed protocol height, same canonical chain) and affects ANY indexer-enabled operator. Consensus is unaffected — the indexer is a read-only, fully reindex-reproducible consumer on a separate redb file. Fix: split the flip-helper's "no entry of either sign present" case into a distinct IndexerError::SegmentEntryMissing variant, and make the template and token sign-flips on spend (apply) AND their unflips on rollback tolerate ONLY that variant — log a loud WARN, bump a process-wide degraded counter (`secondary_index_drift_skips()`), and skip rather than halt. Everything else still propagates and halts, INCLUDING the sibling SegmentTopologyError (double-flip / missing-spill / pop-mismatch) and DB decode/redb errors, so genuine structural corruption is never masked. The PRIMARY address index stays strict (SegmentEntryMissing there still halts, mapped to DbCorruption). Rollback is covered so a reorg after a degraded apply does not re-halt. This mirrors the reference node's ExtraIndexer (log-and-continue on a findAndModBox miss). Recovery is automatic: the halt is never persisted and the failed apply never committed, so a node that updates to this binary and restarts resumes from the last good height, re-applies the previously-fatal block (now tolerated), and catches up to tip — no reindex or manual step. The index is then degraded only for the affected box(es); a chain-free secondary-index rebuild (follow-up) restores full correctness without a full reindex. Follow-ups (not in this PR): surface the degraded count on the indexer status API; add reorg-shape tests (boundary-cross from a head==512 pre-state, content-overlapping forks over a shared template, spend of a deep-spill box post-reorg) to determine whether a live reorg path can still create the gap vs. it being stale historical on-disk data; and an opt-in chain-free rebuild that re-derives template/token segments from the intact primary box table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the degrade-not-halt fix: instead of leaving the template/token index permanently degraded (the affected box missing) after a tolerated SegmentEntryMissing drift, the node now auto-repairs to FULL correctness on update, with no operator action and without a full chain reindex. Mechanism: - Detect: when a secondary flip is skipped, apply/rollback set a sticky `secondary_repair_pending` marker in INDEXER_META, in the SAME write txn (durable iff the degraded block commits). It is NOT an IndexerMeta/UndoEntry field, so a reorg meta-restore can't clear it — sticky until a rebuild does. - Repair (rebuild.rs): a chain-free rebuild re-derives template + token box-segments from the intact primary tables (NUMERIC_BOX + INDEXED_BOX, which retain spent boxes). Phase 0 wipes template/token segments (preserving token metadata + the shared ADDRESS spills); Phase 1 scans gi in order, REUSING the apply append/flip/flush path so segments are byte-identical to a fresh index. Chunked by gi with a per-chunk INDEXER_META checkpoint → bounded memory + crash-resumable; Phase 0 is idempotent and its completion gates Phase 1. - Trigger (task.rs): at the caught-up transition, before flipping to CaughtUp (which opens the gated read API), the task runs/resumes the rebuild while status stays Syncing — so a half-rebuilt index is never served. Net: update the binary → catch up → repair → serve fully-correct, in ONE restart. Consensus untouched (writes only indexer.redb secondary tables). Tests: an integration test proves the rebuild reproduces template segments exactly (incl. the spent flip), leaves the address index untouched, and clears the marker; a unit test pins the marker set/read/clear + the sticky-across- write_meta property. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion opaque trees) The chain-free rebuild (and every index-read path: by-id / by-address / by-template materialization, apply, rollback) re-reads boxes from the intact primary INDEXED_BOX table through the STRICT consensus box reader, which hard-rejects an ErgoTree whose header version exceeds the activated script version (3) even when size-delimited (opaque). A legacy mainnet box can carry such a tree (stored by an older node before those gates existed; confirmed live: global index 5918565, header 0xcd = version 5), so the rebuild halted with a spurious "db-corruption", and those rows were un-materializable everywhere. Re-validating consensus while reading the node's OWN already-validated stored data is wrong. Add a `trusted` flag to `VlqReader` (default off; `.trusted()` / `set_trusted`): when set, the four box-script acceptance gates are skipped — for the TOP-LEVEL tree and for any tree NESTED in a register, `SBox` constant, or spending-proof context-extension reached through the same reader. The flag rides on the reader, so registers and context-extensions (read inline) inherit it automatically; the size-delimited ErgoTree BODY sub-reader propagates it explicitly (`read_ergo_tree_tracking_wrap`). The full structural `read_ergo_tree` parse still runs, so `ergo_tree_bytes` / template-hash derivation are byte-identical. `deserialize_indexed_box` is the ONLY production caller that sets `trusted` — every INDEXED_BOX write originates from a typed, consensus-validated block transaction, never untrusted bytes. The strict consensus readers are byte-for-byte unchanged (the gates run whenever `!is_trusted()`). Regression tests cover the real top-level box, a nested SBox constant, and a nested tree inside a size-delimited body (strict reject vs trusted accept). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rker, interruptible shutdown Three robustness layers on the chain-free secondary-index rebuild, so it always completes, never silently lies about completeness, and never wedges a node on shutdown: - Skip-not-halt fallback: a primary row that even the trusted/lenient reader cannot decode (genuine corruption, not a tolerable high-version tree) is logged (gi+box_id), counted, and SKIPPED rather than halting the whole rebuild. Bounded strictly to DbDecode from the box read; a missing NUMERIC_BOX/INDEXED_BOX row is structural corruption and still halts. Skipping one box does not desync later segment math (entries are absolute gi; flips search by value). - Honest completed-with-skips marker: a new INDEXER_META `secondary_repair_skipped` count, written with each chunk checkpoint (resume-safe accumulation) and reset at Phase-0 start. On completion the pending marker + checkpoint clear, but a non-zero skip count is KEPT as a durable "knowingly incomplete" signal and the rebuild WARNs instead of logging "fully repaired". Exposed via `IndexerStore::secondary_repair_skipped()`. - Interruptible rebuild: the rebuild previously ran every chunk inside one `step()` call, so a node ignored SIGTERM for the entire multi-hour rebuild (a graceful stop timed out in testing). `rebuild_secondary_indexes_until(store, cancel)` now checks the cancel flag between Phase-1 chunks and Phase-0 wipe batches, returning early WITHOUT clearing the marker (the per-chunk checkpoint resumes it). `IndexerTask` threads the driver's cancel handle into the rebuild and, on cancel, does NOT fall through to reorg/forward-apply against a half-rebuilt index. Validated live: graceful stop now drains in ~14s mid-rebuild. Tests: skip-fallback end-to-end (corrupt row skipped, recorded, rebuild completes) and cancel/resume. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump the workspace version 0.4.3 -> 0.5.0 for the indexer self-repair release (degrade-not-halt + chain-free secondary-index rebuild + lenient trusted stored-box reads + rebuild robustness). Refresh Cargo.lock and regenerate the native OpenAPI snapshot fixture, whose embedded info.version is sourced from CARGO_PKG_VERSION. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 25 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughAdds a "degrade-not-halt" secondary index drift tolerance layer to the ergo-indexer: box-spend flips that find no segment entry now produce a new ChangesTrusted VlqReader for lenient stored-box decoding
Secondary index drift tolerance and crash-safe rebuild
Version bump to 0.5.0
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
ergo-indexer/src/lib.rs (1)
34-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep the rebuild module private if only the root API is supported.
Line 34 makes internal rebuild APIs externally reachable, while Line 50 already exposes the manual entrypoint. Prefer keeping the module private to avoid committing checkpoint/cancellation internals as public API.
Proposed change
-pub mod rebuild; +mod rebuild;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ergo-indexer/src/lib.rs` at line 34, The rebuild module is being exported publicly even though only the root API should be supported. Update the module visibility in lib.rs so rebuild stays private, and keep the manual entrypoint exposed through the existing root-level API instead; use the rebuild module name and the root entrypoint export as the main places to adjust.ergo-indexer/src/rebuild.rs (1)
668-722: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExercise a real Phase-1 checkpoint resume.
This test cancels before Phase 0 does any work, so it verifies the marker stays pending but not resume from
secondary_repair_next_gi. Add a case that resumes from a committed checkpoint and asserts entries are not duplicated.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ergo-indexer/src/rebuild.rs` around lines 668 - 722, The current test only cancels before any Phase-1 work, so it never exercises resuming from a committed checkpoint in rebuild_secondary_indexes_until. Update the test to first let rebuild progress far enough to commit a checkpoint in secondary_repair_next_gi, then cancel and rerun to verify the rebuild resumes from that checkpoint rather than starting over. Use the existing rebuild_secondary_indexes_until, rebuild_secondary_indexes, and secondary_repair_pending/secondary_repair_next_gi flow, and assert the template box entries remain exactly once without duplication after the resume.ergo-indexer/src/task.rs (1)
139-171: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winMove the repair gate before the meta/tip snapshot.
rebuild_secondary_indexes_untilcan run for a long time, butmetaandtipare captured before it. Move theread_meta()andcommitted_tip()calls below this gate so post-repair apply/rollback decisions use fresh state and the pending repair path stays chain-free.Suggested structure
- let meta = match store.read_meta() { - Ok(m) => m, - Err(e) => return IndexerPoll::Halted(e), - }; - - let tip = self.chain.committed_tip(); - // Self-repair gate — MUST run before the reorg + forward-apply paths. match store.secondary_repair_pending() { ... } + + let meta = match store.read_meta() { + Ok(m) => m, + Err(e) => return IndexerPoll::Halted(e), + }; + + let tip = self.chain.committed_tip();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ergo-indexer/src/task.rs` around lines 139 - 171, The repair gate in the polling flow is using stale chain state because `read_meta()` and `committed_tip()` are captured before `secondary_repair_pending()` and `rebuild_secondary_indexes_until()`. Move those snapshot reads to after the repair gate in `IndexerTask::poll` so any post-repair reorg or forward-apply decisions use fresh `meta`/`tip` values, and keep the chain-free rebuild path isolated from the rest of the apply/rollback logic.ergo-indexer/tests/rebuild.rs (1)
149-160: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftMake these tests fail for a no-op rebuild.
Both tests snapshot an already-correct secondary segment and only assert
after == before. Seed an actual broken/missing template or token segment beforerebuild_secondary_indexes(&store)so the test proves the rebuild restores from primary tables.Also applies to: 236-245
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ergo-indexer/tests/rebuild.rs` around lines 149 - 160, The rebuild tests are only comparing the secondary indexes before and after `rebuild_secondary_indexes`, so they do not prove recovery from corruption. In the affected test cases in `rebuild.rs`, use the existing `store`, `read_template_box_entries`, `read_address_box_entries`, and `rebuild_secondary_indexes` flow but first deliberately put the template/token secondary segment into a broken or missing state before rebuilding, then assert the rebuilt state matches the primary-table-derived expected entries rather than the original snapshot.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@ergo-indexer/src/segment_buffer.rs`:
- Around line 59-74: Clarify the semantics of the drift skip tracking in
segment_buffer.rs: `SECONDARY_DRIFT_SKIPS` and `secondary_index_drift_skips()`
represent a cumulative process-lifetime counter, not the current live degraded
state. Update the surrounding comment and any usage that treats
`secondary_index_drift_skips() > 0` as an active “queries may be missing”
indicator to instead rely on the durable pending marker or another live health
signal, and make sure the `secondary_index_drift_skips` symbol is documented as
historical/counting-only.
In `@ergo-indexer/src/store/meta.rs`:
- Around line 42-44: Update the checkpoint documentation in the chunk checkpoint
comment on the meta store type so it clearly distinguishes the `None` state from
stored `Some(0)`: `None` should mean Phase 0 wipe is still pending, while
`Some(0)` should mean the wipe has been committed and Phase 1 starts at GI 0.
Keep the wording aligned with the checkpoint handling in `meta.rs` and the
rebuild flow so the contract is unambiguous.
---
Nitpick comments:
In `@ergo-indexer/src/lib.rs`:
- Line 34: The rebuild module is being exported publicly even though only the
root API should be supported. Update the module visibility in lib.rs so rebuild
stays private, and keep the manual entrypoint exposed through the existing
root-level API instead; use the rebuild module name and the root entrypoint
export as the main places to adjust.
In `@ergo-indexer/src/rebuild.rs`:
- Around line 668-722: The current test only cancels before any Phase-1 work, so
it never exercises resuming from a committed checkpoint in
rebuild_secondary_indexes_until. Update the test to first let rebuild progress
far enough to commit a checkpoint in secondary_repair_next_gi, then cancel and
rerun to verify the rebuild resumes from that checkpoint rather than starting
over. Use the existing rebuild_secondary_indexes_until,
rebuild_secondary_indexes, and secondary_repair_pending/secondary_repair_next_gi
flow, and assert the template box entries remain exactly once without
duplication after the resume.
In `@ergo-indexer/src/task.rs`:
- Around line 139-171: The repair gate in the polling flow is using stale chain
state because `read_meta()` and `committed_tip()` are captured before
`secondary_repair_pending()` and `rebuild_secondary_indexes_until()`. Move those
snapshot reads to after the repair gate in `IndexerTask::poll` so any
post-repair reorg or forward-apply decisions use fresh `meta`/`tip` values, and
keep the chain-free rebuild path isolated from the rest of the apply/rollback
logic.
In `@ergo-indexer/tests/rebuild.rs`:
- Around line 149-160: The rebuild tests are only comparing the secondary
indexes before and after `rebuild_secondary_indexes`, so they do not prove
recovery from corruption. In the affected test cases in `rebuild.rs`, use the
existing `store`, `read_template_box_entries`, `read_address_box_entries`, and
`rebuild_secondary_indexes` flow but first deliberately put the template/token
secondary segment into a broken or missing state before rebuilding, then assert
the rebuilt state matches the primary-table-derived expected entries rather than
the original snapshot.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8d1fa660-a185-47dc-a4db-a6640e594464
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (19)
Cargo.tomlergo-api/tests/fixtures/openapi_native.yamlergo-indexer/src/apply.rsergo-indexer/src/error.rsergo-indexer/src/lib.rsergo-indexer/src/rebuild.rsergo-indexer/src/rollback.rsergo-indexer/src/segment_buffer.rsergo-indexer/src/ser/boxes.rsergo-indexer/src/store/meta.rsergo-indexer/src/store/mod.rsergo-indexer/src/store/segment.rsergo-indexer/src/task.rsergo-indexer/tests/error_taxonomy.rsergo-indexer/tests/rebuild.rsergo-primitives/src/reader.rsergo-ser/src/ergo_box.rsergo-ser/src/ergo_tree.rsergo-ser/src/sigma_value.rs
…ics (review feedback) Two doc-accuracy fixes from automated review: - `SECONDARY_DRIFT_SKIPS` / `secondary_index_drift_skips()` is a CUMULATIVE process-lifetime diagnostic counter, NOT a live "index degraded" bit. A successful in-process self-repair rebuild clears the real degradation but does not reset this counter, so a non-zero value can outlive the degradation it counted. Point readers at the durable `secondary_repair_pending` / `secondary_repair_skipped` markers for live state. - `secondary_repair_next_gi`: ABSENT (`None`) and a stored `0` (`Some(0)`) are DISTINCT states the rebuild branches on — absent = Phase-0 wipe not yet committed (wipe must run); `0` = wipe committed, Phase 1 resumes from the first global index. Document the distinction (the old "Absent / 0" conflated them). Doc comments only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @coderabbitai — both doc-accuracy nits addressed in 8d8c4bf:
Doc comments only, no behavior change. |
|
Tip For best results, initiate chat on the files or code changes.
The clarified wording around ✏️ Learnings added
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
rustfmt wrapping for the new code added in this branch (test `.expect()` chains, `assert!` macros, and the meta-key insert). No behavior change. CI runs `cargo fmt --all -- --check`, which this satisfies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rds #146) (#148) Promote the [Unreleased] block to a [0.5.0] - 2026-07-01 section and add the two merged changes that lacked changelog entries: the indexer degrade-not-halt + self-repair fix (#147) and the native wallet retrieve-rewards sweep (#146). The v0.5.0 tag was already cut from main; this brings the changelog in line. Co-authored-by: arkadianet <rkadias@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2A of the first-class UI effort: the node's health machinery becomes observable, and the Charts view graduates from session sparklines to real server-history charts. Indexer health endpoint: - New defaulted `IndexerQuery::health()` (ergo-indexer-types) returning IndexerHealthDto: the durable self-repair markers from the #147 incident machinery (repair_pending / repair_next_gi rebuild cursor / repair_skipped honest marker), the process-lifetime drift-skip counter, and the global box/tx totals. IndexerHandle overrides it with best-effort store reads (a degraded store degrades a field, never errors the snapshot); stubs inherit the healthy default. - New `GET /api/v1/indexer/status` (utoipa-documented, native openapi snapshot regenerated): always-200 and never status-gated — like indexedHeight it must answer while syncing, repairing, or halted. `/blockchain/indexedHeight` stays pinned to its Scala-parity shape; the repair/totals superset lives here. 404 on indexer-less wiring = disabled. Pinned by a new 4-case endpoint test. Overview integration: - The Sync-pipeline panel now surfaces index health, silent when healthy: a progress row while a rebuild runs (cursor/total boxes), a queued row during the wipe phase, a "done · N box(es) skipped" honest-marker row after a knowingly-incomplete repair, and a red halt row with the reason. Real charts (new zero-dep chart.js — axis-labelled SVG line/area + bar histogram with pointer readouts; text lives in HTML around the stretched SVG so nothing distorts; data via textContent only): - Difficulty (last 720 blocks) from /api/v1/difficulty/history — the string-typed difficulty parsed once per point. - Estimated hashrate per point (difficulty / target interval — the same derivation as the KPI band). - Block-interval distribution histogram from the same series' timestamp deltas (<30s … >10m bins). - Mempool age histogram from /transactions/poolHistogram (waiting tx by wait-time bucket). Charts build once and update in place (hover state survives the 4s tick); the series refetches only when the tip height advances. Verified against live mainnet data via the dev proxy (real difficulty epoch steps, 2-minute-modal interval distribution, live pool histogram). fmt/clippy/cargo test --all green; openapi snapshot + runtime-mount + header tests updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… real charts (#151) * feat(ui): node-health surface — indexer self-repair status + real charts Phase 2A of the first-class UI effort: the node's health machinery becomes observable, and the Charts view graduates from session sparklines to real server-history charts. Indexer health endpoint: - New defaulted `IndexerQuery::health()` (ergo-indexer-types) returning IndexerHealthDto: the durable self-repair markers from the #147 incident machinery (repair_pending / repair_next_gi rebuild cursor / repair_skipped honest marker), the process-lifetime drift-skip counter, and the global box/tx totals. IndexerHandle overrides it with best-effort store reads (a degraded store degrades a field, never errors the snapshot); stubs inherit the healthy default. - New `GET /api/v1/indexer/status` (utoipa-documented, native openapi snapshot regenerated): always-200 and never status-gated — like indexedHeight it must answer while syncing, repairing, or halted. `/blockchain/indexedHeight` stays pinned to its Scala-parity shape; the repair/totals superset lives here. 404 on indexer-less wiring = disabled. Pinned by a new 4-case endpoint test. Overview integration: - The Sync-pipeline panel now surfaces index health, silent when healthy: a progress row while a rebuild runs (cursor/total boxes), a queued row during the wipe phase, a "done · N box(es) skipped" honest-marker row after a knowingly-incomplete repair, and a red halt row with the reason. Real charts (new zero-dep chart.js — axis-labelled SVG line/area + bar histogram with pointer readouts; text lives in HTML around the stretched SVG so nothing distorts; data via textContent only): - Difficulty (last 720 blocks) from /api/v1/difficulty/history — the string-typed difficulty parsed once per point. - Estimated hashrate per point (difficulty / target interval — the same derivation as the KPI band). - Block-interval distribution histogram from the same series' timestamp deltas (<30s … >10m bins). - Mempool age histogram from /transactions/poolHistogram (waiting tx by wait-time bucket). Charts build once and update in place (hover state survives the 4s tick); the series refetches only when the tip height advances. Verified against live mainnet data via the dev proxy (real difficulty epoch steps, 2-minute-modal interval distribution, live pool histogram). fmt/clippy/cargo test --all green; openapi snapshot + runtime-mount + header tests updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): clamp bar height before deriving y (CodeRabbit) For 0 < raw-height < 2 the bar rect's y was computed from the raw height while the height itself was clamped to the 2-unit minimum, pushing the bottom edge past the baseline (masked by viewBox clipping). Clamp first, derive y from the clamped value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: arkadianet <rkadias@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Every indexer-enabled node halts deterministically at a fixed mainnet height, reporting
db-corruption. It is not corruption — the chain/UTXO state is intact. A latent bug in the derived (non-consensus) secondary index trips a strict consistency check and treats it as fatal. This PR makes the indexer degrade instead of halt, then self-repair to full correctness — so a node operator who just updates the binary and restarts recovers automatically, with no reindex.Consensus, validation, and the chain database are untouched throughout — this only changes the derived
indexer.redbsecondary index and how it is read.Root cause
The secondary index stores, per address/template/token, a segment of signed global-box-indices
±gi(sign = spent). Creation writes+gi; the spend sign-flips it to−gi. Two latent issues surface together on mainnet:+gientry is absent from the shared template segment. Its first-ever spend lands at its storage-rent eligibility height, the indexer tries to flip a segment entry that was never appended, raisesSegmentTopologyError, and halts asDbCorruption. Same shared code + same on-chain box ⇒ every indexer node stops at the same height.The fix (5 commits)
degrade-not-halt— tolerate only the specificSegmentEntryMissingtemplate/token drift (address index stays strict; structural errors still fatal); set a stickyINDEXER_METArepair marker. The block still commits.NUMERIC_BOX→INDEXED_BOX), reusing the exact apply append/flip machinery so segments are byte-identical to a fresh linear index. Chunked, crash-safe (per-chunk checkpoint), runs at the top of the poll loop before any apply/rollback.VlqReader.trustedflag (default off; set only bydeserialize_indexed_box, which only ever decodes the node's own consensus-validated stored rows) skips the box-script acceptance gates for the top-level tree and any tree nested in a register /SBoxconstant / spending-proof extension / size-delimited body. The structural parse is unchanged, so template-hash derivation is byte-identical. The strict consensus readers are byte-for-byte unchanged (gates run whenever!is_trusted()).chore(release): 0.5.0— version bump + regenerated openapi snapshot.Automatic recovery
A halted node simply updates the binary and restarts: it resumes from the last indexed block, re-attempts the failing block, tolerates the drift (degrade), sets the marker, then runs the rebuild to completion and catches up — no manual reindex, no operator steps.
Validation
cargo clippy --all-targets --all-features -D warnings+cargo test --all(264 suites, 0 failures),cargo fmt --check.Test plan
New tests: real-box top-level lenient regression; nested
SBoxtrusted-vs-strict; nested tree inside a size-delimited body trusted-vs-strict; skip-not-halt fallback end-to-end; cancel/resume; repair-marker stickiness; error-taxonomy. Strict consensus parsing is asserted unchanged (the strict reader still rejects the version-5 box).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Chores
0.5.0.