Summary
Backfill advances oldest_ingest_ledger once per batch, from inside that batch's own transaction, while batches run in parallel. A batch that succeeds above a batch that failed still lowers the cursor. The published cursor then claims contiguous history across ranges that were never written.
Cause
-
Each batch's final flush passes its own start ledger as the cursor value — internal/services/ingest_backfill.go:367, applied at :283 (and :373/:384 when the ledger count is an exact multiple of the insert batch size).
-
UpdateMin is monotonically decreasing — internal/data/ingest_store.go:198-203:
UPDATE ingest_store SET value = LEAST(value::bigint, $2)::text WHERE key = $1
-
Batches execute concurrently, so the final cursor is the minimum start ledger among successful batches. Failed batches return before their flush and contribute nothing. The result has no relationship to which ranges actually landed.
-
reconcile_oldest_cursor cannot repair this. It compares the stored value against MIN(ledger_number) and returns early unless actual_min > stored (internal/ingest/timescaledb.go:223). A gap above the minimum is invisible to it, and it only moves the cursor forward.
Proof
The repo's real UpdateMin statement and the verbatim reconcile_oldest_cursor function, run against Postgres 16. A [1000-1749] backfill as three parallel batches, middle batch succeeds:
batch A [1000-1249] FAILS batch B [1250-1499] OK batch C [1500-1749] FAILS
cursor after backfill "succeeds" (exit 0, see #688): 1250
actual data: min=1250 max=1999 rows=500
gap inside the claimed range: 1500-1749 (250 ledgers missing)
after reconcile_oldest_cursor: 1250 (unchanged)
Lowest batch succeeds instead, middle fails:
batch A [1000-1249] OK batch B [1250-1499] FAILS batch C [1500-1749] OK
cursor: 1000
gap inside the claimed range: 1250-1499 (250 ledgers missing)
after reconcile_oldest_cursor: 1000 (unchanged)
Both cases also satisfy the integration assertion oldest <= expectedOldestLedger (internal/integrationtests/infrastructure/backfill_helpers.go:226), so CI passes.
Consequence
Protocol state is produced for ledgers that have no base rows. The history migration replays from the cursor using the ledger backend, not the transactions table (internal/services/protocol_migrate.go:361). It folds protocol state straight across the gap, so protocol tables describe ledgers whose transactions, operations, and state_changes rows do not exist.
A later repair of the base data does not repair the protocol data. This is the permanent part:
-
A partial backfill leaves the cursor at 1250 and exits 0.
-
protocol-migrate history reads the cursor via ResolveStartLedger (internal/services/protocol_migrate_history.go:90-98), replays from 1250, and sets the history migration status to StatusSuccess.
-
Someone re-runs the backfill for [1000-1249]. It succeeds. The cursor drops to 1000.
-
Re-running protocol-migrate history skips the protocol entirely — internal/services/protocol_migrate.go:207-209:
if s.strategy.MigrationStatusField(p) == data.StatusSuccess {
log.Ctx(ctx).Infof("Protocol %q %s migration already completed, skipping", pid, s.strategy.Label)
continue
}
The gate reads only the stored status, never the cursor. Protocol history for 1000-1249 is never produced, and no command will produce it.
Potential fix
Stop writing the cursor per batch. Collect the batch results, compute the longest contiguous successful prefix downward from the previous cursor value, and issue one UpdateMin for that prefix after every batch has settled. A batch that succeeded above a failed batch then contributes nothing to the cursor, which is correct: its rows exist, but history is not contiguous to it.
Two supporting changes matter as much:
Related
Summary
Backfill advances
oldest_ingest_ledgeronce per batch, from inside that batch's own transaction, while batches run in parallel. A batch that succeeds above a batch that failed still lowers the cursor. The published cursor then claims contiguous history across ranges that were never written.Cause
Each batch's final flush passes its own start ledger as the cursor value —
internal/services/ingest_backfill.go:367, applied at:283(and:373/:384when the ledger count is an exact multiple of the insert batch size).UpdateMinis monotonically decreasing —internal/data/ingest_store.go:198-203:Batches execute concurrently, so the final cursor is the minimum start ledger among successful batches. Failed batches return before their flush and contribute nothing. The result has no relationship to which ranges actually landed.
reconcile_oldest_cursorcannot repair this. It compares the stored value againstMIN(ledger_number)and returns early unlessactual_min > stored(internal/ingest/timescaledb.go:223). A gap above the minimum is invisible to it, and it only moves the cursor forward.Proof
The repo's real
UpdateMinstatement and the verbatimreconcile_oldest_cursorfunction, run against Postgres 16. A[1000-1749]backfill as three parallel batches, middle batch succeeds:Lowest batch succeeds instead, middle fails:
Both cases also satisfy the integration assertion
oldest <= expectedOldestLedger(internal/integrationtests/infrastructure/backfill_helpers.go:226), so CI passes.Consequence
Protocol state is produced for ledgers that have no base rows. The history migration replays from the cursor using the ledger backend, not the
transactionstable (internal/services/protocol_migrate.go:361). It folds protocol state straight across the gap, so protocol tables describe ledgers whosetransactions,operations, andstate_changesrows do not exist.A later repair of the base data does not repair the protocol data. This is the permanent part:
A partial backfill leaves the cursor at 1250 and exits 0.
protocol-migrate historyreads the cursor viaResolveStartLedger(internal/services/protocol_migrate_history.go:90-98), replays from 1250, and sets the history migration status toStatusSuccess.Someone re-runs the backfill for
[1000-1249]. It succeeds. The cursor drops to 1000.Re-running
protocol-migrate historyskips the protocol entirely —internal/services/protocol_migrate.go:207-209:The gate reads only the stored status, never the cursor. Protocol history for 1000-1249 is never produced, and no command will produce it.
Potential fix
Stop writing the cursor per batch. Collect the batch results, compute the longest contiguous successful prefix downward from the previous cursor value, and issue one
UpdateMinfor that prefix after every batch has settled. A batch that succeeded above a failed batch then contributes nothing to the cursor, which is correct: its rows exist, but history is not contiguous to it.Two supporting changes matter as much:
Related
internal/services/ingest_backfill.go:188), so the failed-batch set is itself under-reported.