567 lines
15 KiB
Go
567 lines
15 KiB
Go
package migration1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/davecgh/go-spew/spew"
|
|
"github.com/lightningnetwork/lnd/kvdb"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"github.com/lightningnetwork/lnd/payments/db/migration1/sqlc"
|
|
"github.com/lightningnetwork/lnd/record"
|
|
"github.com/pmezard/go-difflib/difflib"
|
|
)
|
|
|
|
// migratedPaymentRef is a reference to a migrated payment.
|
|
type migratedPaymentRef struct {
|
|
Hash lntypes.Hash
|
|
PaymentID int64
|
|
}
|
|
|
|
// validateMigratedPaymentBatch performs a structural validation pass by
|
|
// comparing key fields (hash, amount, fail reason, HTLC count) of KV payments
|
|
// with their SQL counterparts. If a structural mismatch is detected, a full
|
|
// deep comparison is performed to produce a detailed diff for debugging.
|
|
func validateMigratedPaymentBatch(ctx context.Context,
|
|
kvBackend kvdb.Backend, sqlDB SQLMigrationQueries,
|
|
cfg *SQLStoreConfig, batch []migratedPaymentRef) error {
|
|
|
|
if len(batch) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if cfg == nil || cfg.QueryCfg == nil {
|
|
return fmt.Errorf("missing SQL store config for validation")
|
|
}
|
|
|
|
paymentIDs := make([]int64, 0, len(batch))
|
|
for _, item := range batch {
|
|
paymentIDs = append(paymentIDs, item.PaymentID)
|
|
}
|
|
|
|
rows, err := sqlDB.FetchPaymentsByIDsMig(ctx, paymentIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch SQL payments: %w", err)
|
|
}
|
|
if len(rows) != len(paymentIDs) {
|
|
return fmt.Errorf("SQL payment batch mismatch: got=%d want=%d",
|
|
len(rows), len(paymentIDs))
|
|
}
|
|
|
|
// Perform the structural check by comparing key fields from the KV
|
|
// store with the SQL store.
|
|
err = kvBackend.View(func(kvTx kvdb.RTx) error {
|
|
paymentsBucket := kvTx.ReadBucket(paymentsRootBucket)
|
|
if paymentsBucket == nil {
|
|
return fmt.Errorf("no payments bucket")
|
|
}
|
|
|
|
for _, row := range rows {
|
|
hash := row.PaymentIdentifier
|
|
var paymentHash lntypes.Hash
|
|
copy(paymentHash[:], hash)
|
|
|
|
paymentBucket := paymentsBucket.NestedReadBucket(hash)
|
|
if paymentBucket == nil {
|
|
return fmt.Errorf("missing payment bucket %x",
|
|
hash[:8])
|
|
}
|
|
|
|
kvPayment, err := fetchPayment(paymentBucket)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch KV payment %x: %w",
|
|
hash[:8], err)
|
|
}
|
|
|
|
err = structuralCompare(kvPayment, row)
|
|
if err != nil {
|
|
// On structural mismatch, perform a deep
|
|
// comparison to produce a detailed diff.
|
|
deepErr := deepComparePayment(
|
|
ctx, cfg, sqlDB, row.ID,
|
|
paymentHash, kvPayment,
|
|
)
|
|
if deepErr != nil {
|
|
return deepErr
|
|
}
|
|
|
|
// If deep comparison passes but structural
|
|
// failed, report the structural error as it
|
|
// indicates an unexpected inconsistency.
|
|
return err
|
|
}
|
|
|
|
err = compareDuplicatePayments(
|
|
ctx, paymentBucket, sqlDB, row.ID,
|
|
paymentHash,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}, func() {})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// structuralCompare performs a fast structural comparison between a KV payment
|
|
// and a SQL payment row, checking key fields: payment identifier, amount,
|
|
// failure reason, and HTLC attempt count.
|
|
func structuralCompare(kvPayment *MPPayment,
|
|
sqlRow sqlc.FetchPaymentsByIDsMigRow) error {
|
|
|
|
// Compare payment identifier.
|
|
kvHash := kvPayment.Info.PaymentIdentifier[:]
|
|
if !bytes.Equal(kvHash, sqlRow.PaymentIdentifier) {
|
|
return fmt.Errorf("payment identifier mismatch: kv=%x sql=%x",
|
|
kvHash[:8], sqlRow.PaymentIdentifier[:8])
|
|
}
|
|
|
|
// Compare amount.
|
|
kvAmount := int64(kvPayment.Info.Value)
|
|
if kvAmount != sqlRow.AmountMsat {
|
|
return fmt.Errorf("amount mismatch for %x: kv=%d sql=%d",
|
|
sqlRow.PaymentIdentifier[:8], kvAmount,
|
|
sqlRow.AmountMsat)
|
|
}
|
|
|
|
// Compare failure reason.
|
|
var kvFailReason sql.NullInt32
|
|
if kvPayment.FailureReason != nil {
|
|
kvFailReason = sql.NullInt32{
|
|
Int32: int32(*kvPayment.FailureReason),
|
|
Valid: true,
|
|
}
|
|
}
|
|
if kvFailReason != sqlRow.FailReason {
|
|
return fmt.Errorf("fail reason mismatch for %x: kv=%v sql=%v",
|
|
sqlRow.PaymentIdentifier[:8], kvFailReason,
|
|
sqlRow.FailReason)
|
|
}
|
|
|
|
// Compare HTLC attempt count.
|
|
kvHTLCCount := int64(len(kvPayment.HTLCs))
|
|
if kvHTLCCount != sqlRow.HtlcAttemptCount {
|
|
return fmt.Errorf("HTLC count mismatch for %x: kv=%d sql=%d",
|
|
sqlRow.PaymentIdentifier[:8], kvHTLCCount,
|
|
sqlRow.HtlcAttemptCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// deepComparePayment performs a full deep comparison between a KV payment and
|
|
// its SQL counterpart, producing a detailed diff on mismatch.
|
|
func deepComparePayment(ctx context.Context, cfg *SQLStoreConfig,
|
|
sqlDB SQLQueries, paymentID int64, paymentHash lntypes.Hash,
|
|
kvPayment *MPPayment) error {
|
|
|
|
batchData, err := batchLoadPaymentDetailsData(
|
|
ctx, cfg.QueryCfg, sqlDB, []int64{paymentID},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("load payment data for deep compare %x: %w",
|
|
paymentHash[:8], err)
|
|
}
|
|
|
|
byIDRows, err := sqlDB.FetchPaymentsByIDs(ctx, []int64{paymentID})
|
|
if err != nil {
|
|
return fmt.Errorf("fetch payment by ID for deep compare "+
|
|
"%x: %w", paymentHash[:8], err)
|
|
}
|
|
if len(byIDRows) != 1 {
|
|
return fmt.Errorf("expected 1 payment for deep compare, "+
|
|
"got %d", len(byIDRows))
|
|
}
|
|
|
|
sqlPayment, err := buildPaymentFromBatchData(byIDRows[0], batchData)
|
|
if err != nil {
|
|
return fmt.Errorf("build SQL payment %x: %w",
|
|
paymentHash[:8], err)
|
|
}
|
|
|
|
normalizePaymentForCompare(kvPayment)
|
|
normalizePaymentForCompare(sqlPayment)
|
|
|
|
if !reflect.DeepEqual(kvPayment, sqlPayment) {
|
|
dumpCfg := spew.ConfigState{
|
|
DisablePointerAddresses: true,
|
|
DisableCapacities: true,
|
|
DisableMethods: true,
|
|
SortKeys: true,
|
|
}
|
|
diff := difflib.UnifiedDiff{
|
|
A: difflib.SplitLines(
|
|
dumpCfg.Sdump(kvPayment),
|
|
),
|
|
B: difflib.SplitLines(
|
|
dumpCfg.Sdump(sqlPayment),
|
|
),
|
|
FromFile: "kv",
|
|
ToFile: "sql",
|
|
Context: 3,
|
|
}
|
|
diffText, _ := difflib.GetUnifiedDiffString(diff)
|
|
|
|
return fmt.Errorf("payment mismatch %x\n%s",
|
|
paymentHash[:8], diffText)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// normalizePaymentForCompare normalizes fields that are expected to differ
|
|
// between KV and SQL representations before deep comparison.
|
|
func normalizePaymentForCompare(payment *MPPayment) {
|
|
if payment == nil {
|
|
return
|
|
}
|
|
|
|
// SequenceNum will not be equal because the kv db can have already
|
|
// payments deleted during its lifetime.
|
|
payment.SequenceNum = 0
|
|
|
|
// We normalize timestamps before deep-comparing KV vs SQL objects.
|
|
//
|
|
// - **Microseconds**: SQL backends typically persist timestamps at
|
|
// microsecond precision (e.g. Postgres), while KV (Go `time.Time`)
|
|
// can contain nanoseconds. Truncating avoids false mismatches caused
|
|
// solely by differing storage precision.
|
|
//
|
|
// - **Local timezone**: when reading from SQL, timestamps are typically
|
|
// materialized in the local timezone by the SQL layer (and/or
|
|
// converters). Converting both sides to `time.Local` ensures the
|
|
// comparison is consistent across KV and SQL representations.
|
|
trunc := func(t time.Time) time.Time {
|
|
if t.IsZero() {
|
|
return t
|
|
}
|
|
|
|
return time.Unix(0, t.UnixNano()).
|
|
In(time.Local).
|
|
Truncate(time.Microsecond)
|
|
}
|
|
|
|
// Normalize PaymentCreationInfo fields.
|
|
if payment.Info != nil {
|
|
payment.Info.CreationTime = trunc(
|
|
payment.Info.CreationTime,
|
|
)
|
|
if len(payment.Info.PaymentRequest) == 0 {
|
|
payment.Info.PaymentRequest = []byte{}
|
|
}
|
|
if len(payment.Info.FirstHopCustomRecords) == 0 {
|
|
payment.Info.FirstHopCustomRecords = lnwire.
|
|
CustomRecords{}
|
|
}
|
|
}
|
|
|
|
// Normalize HTLCAttemptInfo so nil is converted to an empty slice.
|
|
if len(payment.HTLCs) == 0 {
|
|
payment.HTLCs = []HTLCAttempt{}
|
|
}
|
|
|
|
// Normalize HTLC attempt ordering; SQL/KV may return attempts
|
|
// in different orders.
|
|
sort.SliceStable(payment.HTLCs, func(i, j int) bool {
|
|
return payment.HTLCs[i].AttemptID < payment.HTLCs[j].AttemptID
|
|
})
|
|
|
|
// Normalize HTLCAttemptInfo fields.
|
|
for i := range payment.HTLCs {
|
|
htlc := &payment.HTLCs[i]
|
|
|
|
htlc.AttemptTime = trunc(htlc.AttemptTime)
|
|
if htlc.Settle != nil {
|
|
htlc.Settle.SettleTime = trunc(
|
|
htlc.Settle.SettleTime,
|
|
)
|
|
}
|
|
if htlc.Failure != nil {
|
|
htlc.Failure.FailTime = trunc(
|
|
htlc.Failure.FailTime,
|
|
)
|
|
}
|
|
|
|
// Clear cached fields not persisted in storage.
|
|
htlc.onionBlob = [1366]byte{}
|
|
htlc.circuit = nil
|
|
htlc.cachedSessionKey = nil
|
|
|
|
// For legacy payments, the HTLC Hash field may be nil in the
|
|
// bbolt backend. During migration, the SQL code uses the
|
|
// parent payment hash as fallback. To ensure the comparison
|
|
// between bbolt and SQL data succeeds, we apply the same
|
|
// fallback here.
|
|
//
|
|
// See also: patchLegacyPaymentHash in payment_lifecycle.go.
|
|
if htlc.Hash == nil && payment.Info != nil {
|
|
htlc.Hash = &payment.Info.PaymentIdentifier
|
|
}
|
|
|
|
if len(htlc.Route.FirstHopWireCustomRecords) == 0 {
|
|
htlc.Route.FirstHopWireCustomRecords =
|
|
lnwire.CustomRecords{}
|
|
}
|
|
|
|
for j := range htlc.Route.Hops {
|
|
if len(htlc.Route.Hops[j].CustomRecords) == 0 {
|
|
htlc.Route.Hops[j].CustomRecords =
|
|
record.CustomSet{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// duplicateRecord is a record that represents a duplicate payment.
|
|
type duplicateRecord struct {
|
|
AmountMsat int64
|
|
CreatedAt time.Time
|
|
FailReason sql.NullInt32
|
|
SettlePreimage []byte
|
|
SettleTime sql.NullTime
|
|
}
|
|
|
|
// compareDuplicatePayments validates migrated duplicate rows against KV data.
|
|
func compareDuplicatePayments(ctx context.Context, paymentBucket kvdb.RBucket,
|
|
sqlDB SQLQueries, paymentID int64, hash lntypes.Hash) error {
|
|
|
|
// Fetch the duplicate payments from the KV store.
|
|
kvDuplicates, err := fetchDuplicateRecords(paymentBucket)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch KV duplicates %x: %w",
|
|
hash[:8], err)
|
|
}
|
|
|
|
// Fetch the duplicate payments from the SQL store.
|
|
sqlDuplicates, err := sqlDB.FetchPaymentDuplicates(ctx, paymentID)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch SQL duplicates %x: %w",
|
|
hash[:8], err)
|
|
}
|
|
|
|
if len(kvDuplicates) != len(sqlDuplicates) {
|
|
return fmt.Errorf("duplicate count mismatch %x: kv=%d "+
|
|
"sql=%d", hash[:8], len(kvDuplicates),
|
|
len(sqlDuplicates))
|
|
}
|
|
|
|
kvNormalized := normalizeDuplicateRecords(kvDuplicates)
|
|
sqlNormalized := normalizeDuplicateRecords(
|
|
dbDuplicatesToDuplicateRecords(sqlDuplicates),
|
|
)
|
|
|
|
sortDuplicates(kvNormalized)
|
|
sortDuplicates(sqlNormalized)
|
|
|
|
if !reflect.DeepEqual(kvNormalized, sqlNormalized) {
|
|
dumpCfg := spew.ConfigState{
|
|
DisablePointerAddresses: true,
|
|
DisableCapacities: true,
|
|
DisableMethods: true,
|
|
SortKeys: true,
|
|
}
|
|
diff := difflib.UnifiedDiff{
|
|
A: difflib.SplitLines(
|
|
dumpCfg.Sdump(kvNormalized),
|
|
),
|
|
B: difflib.SplitLines(
|
|
dumpCfg.Sdump(sqlNormalized),
|
|
),
|
|
FromFile: "kv",
|
|
ToFile: "sql",
|
|
Context: 3,
|
|
}
|
|
diffText, _ := difflib.GetUnifiedDiffString(diff)
|
|
|
|
return fmt.Errorf("duplicate mismatch %x\n%s",
|
|
hash[:8], diffText)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fetchDuplicateRecords reads duplicate payment records from the KV bucket.
|
|
func fetchDuplicateRecords(paymentBucket kvdb.RBucket) ([]duplicateRecord,
|
|
error) {
|
|
|
|
dupBucket := paymentBucket.NestedReadBucket(duplicatePaymentsBucket)
|
|
if dupBucket == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var duplicates []duplicateRecord
|
|
err := dupBucket.ForEach(func(seqBytes, _ []byte) error {
|
|
if len(seqBytes) != 8 {
|
|
return nil
|
|
}
|
|
|
|
subBucket := dupBucket.NestedReadBucket(seqBytes)
|
|
if subBucket == nil {
|
|
return nil
|
|
}
|
|
|
|
creationData := subBucket.Get(duplicatePaymentCreationInfoKey)
|
|
if creationData == nil {
|
|
return fmt.Errorf("missing duplicate creation info")
|
|
}
|
|
|
|
creationInfo, err := deserializeDuplicatePaymentCreationInfo(
|
|
bytes.NewReader(creationData),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("deserialize duplicate creation "+
|
|
"info: %w", err)
|
|
}
|
|
|
|
settleData := subBucket.Get(duplicatePaymentSettleInfoKey)
|
|
failReasonData := subBucket.Get(duplicatePaymentFailInfoKey)
|
|
|
|
if settleData != nil && len(failReasonData) > 0 {
|
|
return fmt.Errorf("duplicate has both settle and " +
|
|
"fail info")
|
|
}
|
|
|
|
var (
|
|
failReason sql.NullInt32
|
|
settlePreimage []byte
|
|
settleTime sql.NullTime
|
|
)
|
|
|
|
switch {
|
|
case settleData != nil:
|
|
settlePreimage, settleTime, err =
|
|
parseDuplicateSettleData(settleData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case len(failReasonData) > 0:
|
|
failReason = sql.NullInt32{
|
|
Int32: int32(failReasonData[0]),
|
|
Valid: true,
|
|
}
|
|
default:
|
|
// If the duplicate has no settle or fail info, it is
|
|
// considered failed. Every duplicate payment must have
|
|
// either a settle or fail info in the sql database. So
|
|
// we set the fail reason to error to mimic the behavior
|
|
// for the kv store.
|
|
failReason = sql.NullInt32{
|
|
Int32: int32(FailureReasonError),
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
duplicates = append(duplicates, duplicateRecord{
|
|
AmountMsat: int64(creationInfo.Value),
|
|
CreatedAt: normalizeTimeForSQL(
|
|
creationInfo.CreationTime,
|
|
),
|
|
FailReason: failReason,
|
|
SettlePreimage: settlePreimage,
|
|
SettleTime: settleTime,
|
|
})
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return duplicates, nil
|
|
}
|
|
|
|
// dbDuplicatesToDuplicateRecords maps SQL duplicate rows into comparable
|
|
// duplicate records.
|
|
func dbDuplicatesToDuplicateRecords(
|
|
rows []sqlc.PaymentDuplicate) []duplicateRecord {
|
|
|
|
duplicates := make([]duplicateRecord, 0, len(rows))
|
|
for _, row := range rows {
|
|
duplicates = append(duplicates, duplicateRecord{
|
|
AmountMsat: row.AmountMsat,
|
|
CreatedAt: row.CreatedAt,
|
|
FailReason: row.FailReason,
|
|
SettlePreimage: row.SettlePreimage,
|
|
SettleTime: row.SettleTime,
|
|
})
|
|
}
|
|
|
|
return duplicates
|
|
}
|
|
|
|
// normalizeDuplicateRecords normalizes time precision and empty fields.
|
|
func normalizeDuplicateRecords(records []duplicateRecord) []duplicateRecord {
|
|
if len(records) == 0 {
|
|
return []duplicateRecord{}
|
|
}
|
|
|
|
trunc := func(t time.Time) time.Time {
|
|
if t.IsZero() {
|
|
return t
|
|
}
|
|
|
|
return t.In(time.Local).Truncate(time.Microsecond)
|
|
}
|
|
|
|
for i := range records {
|
|
records[i].CreatedAt = trunc(records[i].CreatedAt)
|
|
if records[i].SettleTime.Valid {
|
|
records[i].SettleTime.Time = trunc(
|
|
records[i].SettleTime.Time,
|
|
)
|
|
}
|
|
|
|
if len(records[i].SettlePreimage) == 0 {
|
|
records[i].SettlePreimage = []byte{}
|
|
}
|
|
}
|
|
|
|
return records
|
|
}
|
|
|
|
// sortDuplicates orders records deterministically for deep comparison.
|
|
func sortDuplicates(records []duplicateRecord) {
|
|
sort.SliceStable(records, func(i, j int) bool {
|
|
ai := records[i]
|
|
aj := records[j]
|
|
|
|
// Duplicates are "duplicates" because they share the same
|
|
// payment identifier. So ordering can be stable using
|
|
// timestamp + amount.
|
|
if !ai.CreatedAt.Equal(aj.CreatedAt) {
|
|
return ai.CreatedAt.Before(aj.CreatedAt)
|
|
}
|
|
|
|
return ai.AmountMsat < aj.AmountMsat
|
|
})
|
|
}
|
|
|
|
// validatePaymentCounts compares the number of migrated payments with the SQL
|
|
// payment count to catch missing rows.
|
|
func validatePaymentCounts(ctx context.Context, sqlDB SQLQueries,
|
|
expectedCount int64) error {
|
|
|
|
sqlCount, err := sqlDB.CountPayments(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("count SQL payments: %w", err)
|
|
}
|
|
if expectedCount != sqlCount {
|
|
return fmt.Errorf("payment count mismatch: kv=%d sql=%d",
|
|
expectedCount, sqlCount)
|
|
}
|
|
|
|
return nil
|
|
}
|