Summary
pushWithTombstone (internal/indexer/indexer_buffer.go) nets a create+remove of the same key within one ledger to nothing via a tombstone. That is correct only if the CREATE is folded before the REMOVE. Within a single transaction, changes are appended while ranging the opsParticipants map in processTransaction (internal/indexer/indexer.go), so the relative order of a CREATE-op change and a REMOVE-op change is nondeterministic — which flips the result.
Behavior (for a tx that both creates and removes the same key: CreateAccount+AccountMerge; ChangeTrust add + ChangeTrust limit-0)
- CREATE folds first → REMOVE hits
isNoopRemove, key deleted + tombstone recorded → nets to nothing ✓
- REMOVE folds first → no existing entry, so
isNoopRemove (guarded by exists) never fires; REMOVE stored as a phantom with no tombstone, and the lower-order CREATE is then dropped at the order(existing) > order(change) check → a spurious REMOVE persists ✗
Same ledger + input, different output between runs. Cross-transaction (create in tx1, remove in tx2 of the same ledger) is safe — results fold in tx-index order.
Impact
Nondeterministic ingestion output for identical input (reproducibility / backfill consistency). The downstream effect of the spurious REMOVE depends on how REMOVE changes are applied (may be an idempotent no-op).
Scope note
Pre-existing in the tombstone code introduced by #641; not introduced or worsened by #648 (which preserves the within-tx map-iteration order — its "dedup/tombstone semantics unchanged" claim holds for this hazard). Filed separately so it's tracked against the owning code.
Fix direction
Fold each change slice in ascending order value before dedup (sort by SortKey/OperationID), or emit changes ordered from processTransaction, so CREATE always precedes REMOVE. Add a regression test with a create+remove-in-one-tx ledger — the map iteration means a naive test passes most runs.
Summary
pushWithTombstone(internal/indexer/indexer_buffer.go) nets a create+remove of the same key within one ledger to nothing via a tombstone. That is correct only if the CREATE is folded before the REMOVE. Within a single transaction, changes are appended while ranging theopsParticipantsmap inprocessTransaction(internal/indexer/indexer.go), so the relative order of a CREATE-op change and a REMOVE-op change is nondeterministic — which flips the result.Behavior (for a tx that both creates and removes the same key:
CreateAccount+AccountMerge;ChangeTrustadd +ChangeTrustlimit-0)isNoopRemove, key deleted + tombstone recorded → nets to nothing ✓isNoopRemove(guarded byexists) never fires; REMOVE stored as a phantom with no tombstone, and the lower-order CREATE is then dropped at theorder(existing) > order(change)check → a spurious REMOVE persists ✗Same ledger + input, different output between runs. Cross-transaction (create in tx1, remove in tx2 of the same ledger) is safe — results fold in tx-index order.
Impact
Nondeterministic ingestion output for identical input (reproducibility / backfill consistency). The downstream effect of the spurious REMOVE depends on how REMOVE changes are applied (may be an idempotent no-op).
Scope note
Pre-existing in the tombstone code introduced by #641; not introduced or worsened by #648 (which preserves the within-tx map-iteration order — its "dedup/tombstone semantics unchanged" claim holds for this hazard). Filed separately so it's tracked against the owning code.
Fix direction
Fold each change slice in ascending order value before dedup (sort by
SortKey/OperationID), or emit changes ordered fromprocessTransaction, so CREATE always precedes REMOVE. Add a regression test with a create+remove-in-one-tx ledger — the map iteration means a naive test passes most runs.