Skip to content

feat(migrate): --rebuild — wipe and re-derive a protocol's indexed state - #692

Open
aditya1702 wants to merge 12 commits into
main-blendfrom
blend/protocol-migrate-rebuild
Open

feat(migrate): --rebuild — wipe and re-derive a protocol's indexed state#692
aditya1702 wants to merge 12 commits into
main-blendfrom
blend/protocol-migrate-rebuild

Conversation

@aditya1702

@aditya1702 aditya1702 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What this adds

Two commands that fix a protocol's corrupted data after a bug fix ships:

protocol-migrate current-state --protocol-id SEP41 --start-ledger <first ledger> --rebuild
protocol-migrate history       --protocol-id SEP41 --rebuild

Each command wipes the protocol's rows, then re-runs the normal migration. One invocation, no separate wipe step. Live ingestion keeps running.

Why wipe + remigrate

Columns like Blend cost basis and SEP-41 balances are running totals. The chain does not store them anywhere. The only fix that works for every protocol is replaying all events from the start.

Rejected alternatives:

  1. Ask the chain — only some columns have an on-chain answer. A tool that fixes some cases is confusing.
  2. Recompute from state_changes — retention drops old rows, and a decode bug corrupts those rows too.
  3. Auto-repair on restart — a day-long destructive rebuild needs a human decision, not a pod restart.

How it works

Both rebuilds have the same shape: validate → lock → wipe → run the normal migration. The migrate engine itself is untouched by this PR.

Current-state rebuild:

  1. Refuse if a migration is marked in_progress.
  2. One transaction: delete the rows, reset the cursor, reset the status.
  3. Run the migration from --start-ledger. It hands off to live ingestion at the tip, like any migration.

History rebuild:

  1. Refuse if a migration is marked in_progress.
  2. Reset the cursor and status first. This stops live from writing this protocol's history, so the deletes race nothing.
  3. Delete the protocol's rows in 10k-ledger slices (the table is compressed — the constant's comment explains the slicing).
  4. Run the migration over the retained window. Live resumes at handoff.

Safety

  • contract_tokens, protocol_wasms, protocol_contracts are never touched. Nothing can rebuild them.
  • The cursor row is updated, never deleted. Live shuts down if it vanishes.
  • The compressed-table delete prunes batches using existing metadata. No new index.
  • Reruns are safe. Wipes are idempotent and row IDs are deterministic.

Locks

One advisory lock per protocol per strategy. Only one current-state run and one history run can touch a protocol at a time. History migrations gain mutual exclusion they didn't have before.

Trade-offs (accepted on purpose)

  1. Nothing verifies our numbers against the chain. A rebuild re-runs our own code.
  2. Rebuild time grows with the chain (~hundreds of ledgers/s from the lake).
  3. The protocol serves partial data during its rebuild. History writes pause until handoff.
  4. A history fix always re-derives the full retained window. Range-scoped rebuilds were dropped for simplicity; they come back if history migration becomes a backfill.

Testing

  • Unit: wipe/reset semantics, rollback on failed wipe, in_progress refusal, delete slice edges, lock behavior (including a failed multi-protocol acquire freeing its locks).
  • Integration (full suite green): corrupt one Blend row's amount, delete another, run history --rebuild. Both come back correct; other namespaces, contract_tokens, and statuses are untouched.
  • make check green.

After merge: run both rebuilds for SEP41 in dev and confirm the known bad balance (CAZXR…EXSND) is fixed.

🤖 Generated with Claude Code

…otocol advisory lock

Current-state migrations (and the upcoming rebuild mode) must not write the
same protocol's tables concurrently. Each run try-locks a per-protocol
advisory lock on a dedicated connection for its duration; a held lock fails
the run before any status is marked. Live ingestion is deliberately not a
party — the per-ledger cursor CAS already arbitrates which writer folds a
given ledger. Plain history migration is append-only and does not contend.
…ry bounded backfill

Current-state rebuild (engine.rebuild): after taking the per-protocol
advisory lock, one transaction per protocol resets the migration cursor to
start-ledger − 1 and deletes every current-state row via the processor's
WipeCurrentState. The cursor UPDATE takes the row lock live ingestion's
per-ledger CAS needs, so live serializes against the wipe and skips the
protocol's folds until the migration hands ownership back at the frontier.
validate() re-admits protocols whose migration already succeeded and refuses
in_progress residue.

History rebuild (bounded fold): history rows are per-ledger records with no
running totals, so any range rebuilds in isolation. The service validates a
COMPLETED history migration, clamps the range to [oldest retained ledger,
committed history frontier], deletes the protocol's state_change_id-namespace
rows in 10k-ledger slices (each its own transaction with the DML decompression
cap lifted; to_id carries the ledger in its high 32 bits, so chunk skipping
and compressed-batch minmax metadata prune every slice), then re-derives the
range through the migrate pipeline with no cursor reads/writes, no
live-frontier gating, and no CAS.

Wipes never touch contract_tokens, protocol_wasms, or protocol_contracts —
classification owns those and nothing rebuilds them.
current-state gains --rebuild; history gains --rebuild with optional
--from-ledger/--to-ledger (valid only with --rebuild, defaulting to the full
retained window). Help text states the destructiveness plainly. The history
rebuild honors --oldest-ledger-cursor-name the same way the plain history
migration does.
…y delete

toid.AfterLedger is the maximum to_id within a ledger, not the first of the
next one, so the upper bound must be inclusive. Also wrap the errors the wipe
wrappers return.
Unit coverage: current-state rebuild re-admits succeeded protocols, wipes
exactly once, and resets the cursor in the same transaction (a failed wipe
rolls the reset back); in_progress residue refuses before any wipe; non-rebuild
behavior unchanged. History rebuild: status/classification validation, range
clamping to [oldest retained, lowest committed frontier], 10k-ledger delete
slicing verified at every slice edge, bounded folds that never touch the
cursor, and advisory-lock refusal. Data layer: namespace ∩ ledger-range
deletes across all three namespaces, idempotent on retry.

Integration: BlendMigrationTestSuite gains a phase that deletes one migrated
BORROW state change, runs protocol-migrate history --rebuild over exactly that
ledger, and asserts the row returns with other namespaces, contract_tokens,
the cursor, and both migration statuses untouched.
The cursor name was configurable via --oldest-ledger-cursor-name on both
ingest and protocol-migrate history, but nothing ever needed a non-default
value, and a mismatch between the two commands silently breaks history
migration's start-ledger resolution. The key is now the
data.OldestLedgerCursorName constant everywhere, matching the hard-coded
latest-ledger cursor.
…cenario

The rebuild exists for rows that are present with wrong values, not just
missing ones. The integration phase now corrupts the REPAY amount in place,
deletes the BORROW row, rebuilds the multi-ledger range spanning both, and
asserts the corrupted value is replaced by the re-derived one and the deleted
row returns. Because state_change_ids are deterministic, the corrupted row's
repair specifically witnesses the rebuild's delete step — a silently failing
delete would leave the wrong amount in place.
The lock had only indirect coverage through the migration and rebuild Run
paths. Pin the primitive itself: deterministic per-protocol IDs that differ
across protocols, refuse-while-held with release restoring acquirability,
no contention between distinct protocols, and — the cleanup path — a
multi-protocol acquisition failing mid-list frees the locks it already took
instead of wedging every protocol before the held one.
Every command's Run now takes its own strategy's per-protocol advisory lock
(current-state scope or history scope) and the engine takes none — it is pure
migrate machinery again, with no lock flag, no rebuild flag, and no wipe
logic. History migrations gain mutual exclusion they previously lacked.

The current-state rebuild moves out of the engine into its own service,
mirroring the history rebuild: validate → lock → wipe → re-derive. Its wipe
transaction resets the migration status to not_started alongside the cursor
and the row deletes, which is what lets it reuse the engine's normal
lifecycle unmodified.

The current-state lock key bytes are unchanged; the history scope adds a new
key.
The bounded fold shared only the shape of the engine loop, none of its
machinery — cursor init, frontier gating, CAS, handoff, and per-window
membership refresh are all live-race concerns a below-frontier replay never
has. reDerive is a plain fetch/extract/process/persist loop over the
inclusive range, loading membership once (classification for every ledger at
or below the frontier committed before the run started). The engine loses its
bounded mode and returns to exactly its pre-rebuild form.
…, mirroring current-state

The history rebuild now shares the current-state rebuild's shape exactly:
validate → lock → wipe → run the unmodified migration engine. Its wipe resets
the protocol's cursor to the retention floor and the migration status to
not_started (one transaction, committed before any delete so live ingestion
stops writing the protocol's history and the deletes race nothing), then
removes the protocol's rows over the retained window in ledger slices. The
engine folds from the floor and hands off to live at the frontier, exactly as
a first migration does.

This drops --from-ledger/--to-ledger and the dedicated replay loop. Range
scoping traded per-fix speed for symmetry; it returns if history migration is
later folded into backfilling, where the range-scoped delete+re-derive is the
natural shape.

Copilot AI 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.

Pull request overview

Adds destructive rebuild support for protocol current state and history while coordinating migrations with advisory locks.

Changes:

  • Adds --rebuild migration modes with protocol-specific wipes.
  • Adds scoped advisory locking and sliced history deletion.
  • Standardizes the oldest-ingest cursor and expands rebuild tests.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
cmd/ingest.go Removes configurable oldest-cursor flag.
cmd/protocol_migrate.go Adds rebuild CLI options.
internal/data/blend/wipe.go Wipes Blend current-state tables.
internal/data/ingest_store_test.go Updates canonical cursor test keys.
internal/data/sep41/wipe.go Wipes SEP-41 current state.
internal/data/statechanges.go Adds namespace/range deletion.
internal/data/statechanges_test.go Tests scoped history deletion.
internal/ingest/ingest.go Uses the canonical oldest cursor.
internal/ingest/timescaledb_test.go Updates cursor names in tests.
internal/integrationtests/blend_test.go Tests Blend history repair.
internal/services/blend/processor.go Exposes Blend current-state wipe.
internal/services/ingest.go Removes cursor-name configuration.
internal/services/ingest_backfill.go Uses the canonical cursor.
internal/services/ingest_live.go Uses the canonical cursor.
internal/services/ingest_live_test.go Updates ingestion setup.
internal/services/ingest_test.go Updates ingestion fixtures and processors.
internal/services/mocks.go Adds processor wipe mock.
internal/services/protocol_migrate_current_state.go Locks current-state migrations.
internal/services/protocol_migrate_current_state_rebuild.go Implements current-state rebuilds.
internal/services/protocol_migrate_current_state_test.go Tests current-state locking.
internal/services/protocol_migrate_history.go Locks history migrations.
internal/services/protocol_migrate_history_rebuild.go Implements sliced history rebuilds.
internal/services/protocol_migrate_history_test.go Tests history locking.
internal/services/protocol_migrate_lock.go Implements scoped advisory locks.
internal/services/protocol_migrate_lock_test.go Tests lock behavior.
internal/services/protocol_migrate_rebuild_test.go Tests rebuild workflows.
internal/services/protocol_migrate_test.go Extends the test processor.
internal/services/protocol_processor.go Adds the wipe interface method.
internal/services/sep41/processor.go Exposes SEP-41 current-state wipe.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +15 to +16
assert.Equal(t, migrateAdvisoryLockID(lockScopeCurrentState, "SEP41"), migrateAdvisoryLockID(lockScopeCurrentState, "SEP41"),
"the lock ID must be deterministic — every writer must derive the same key for a protocol")
Comment on lines +15 to +19
for _, table := range []string{
"blend_pools",
"blend_positions",
"blend_reserves",
"blend_backstop_positions",
return fmt.Errorf("resetting history migration state for %s: %w", protocolID, txErr)
}

base := s.engine.processors[protocolID].StateChangeOrdinalBase()
Comment on lines +14 to +15
for _, table := range []string{"sep41_balances", "sep41_allowances"} {
if _, err := dbTx.Exec(ctx, "DELETE FROM "+table); err != nil {
Comment thread cmd/protocol_migrate.go
Comment on lines 289 to +290
func(cmd *cobra.Command, opts *migrationCommandOpts) {
cmd.Flags().StringVar(&oldestLedgerCursorName, "oldest-ledger-cursor-name", data.OldestLedgerCursorName, "Name of the oldest ledger cursor in the ingest store. Must match the value used by the ingest service.")
cmd.Flags().BoolVar(&rebuild, "rebuild", false, "Delete the protocol's history rows and rebuild them from the oldest retained ledger. Destructive: the protocol's history serves empty/partial data until the rebuild reaches the live frontier.")
@aristidesstaffieri

Copy link
Copy Markdown
Contributor

We should update the migration documentation to include the parts needed for the rebuild, like the wipe logic.

"blend_oracle_prices",
"blend_auctions",
} {
if _, err := dbTx.Exec(ctx, "DELETE FROM "+table); err != nil {

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.

any reason not to just truncate here?

Since the update at the beginning of the transaction in protocol_migrate_current_state_rebuild.go takes a lock, it blocks ingestion for all protocols until the delete completes which could be expensive for some protocols and is unbounded, blocking indefinitely.


// retainedWindow returns the wipe bounds: oldest retained ledger through
// live ingestion's committed tip.
func (s *protocolHistoryRebuildService) retainedWindow(ctx context.Context) (uint32, uint32, error) {

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.

can this also check start > latest break or oldest <= latest to avoid operator mistakes that throw this into a loop?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants