Also freeze the lnwire and record packages used by the migration. Copy the minimal subset of lnwire files (16) into payments/db/migration1/lnwire/ and all record files (6) into payments/db/migration1/record/. Three lnwire files are trimmed to avoid pulling in the full message-type dispatch tree — all changes are purely subtractive and can be verified with: diff lnwire/message.go payments/db/migration1/lnwire/message.go diff lnwire/writer.go payments/db/migration1/lnwire/writer.go diff lnwire/lnwire.go payments/db/migration1/lnwire/lnwire.go All migration1 files now import only the frozen packages, removing the live dependency on lnwire and record so future changes to those packages cannot affect migration correctness.
821 lines
22 KiB
Go
821 lines
22 KiB
Go
package migration1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/lightningnetwork/lnd/kvdb"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/payments/db/migration1/lnwire"
|
|
"github.com/lightningnetwork/lnd/payments/db/migration1/sqlc"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// MigrationStats tracks migration progress.
|
|
type MigrationStats struct {
|
|
TotalPayments int64
|
|
SuccessfulPayments int64
|
|
FailedPayments int64
|
|
InFlightPayments int64
|
|
InitiatedPayments int64
|
|
TotalAttempts int64
|
|
SettledAttempts int64
|
|
FailedAttempts int64
|
|
InFlightAttempts int64
|
|
TotalHops int64
|
|
DuplicatePayments int64
|
|
DuplicateEntries int64
|
|
SkippedPayments int64
|
|
MigrationDuration time.Duration
|
|
}
|
|
|
|
// MigratePaymentsKVToSQL migrates payments from KV to SQL and validates
|
|
// migrated data in batches. Callers are responsible for executing this within
|
|
// a single SQL transaction if atomicity is required.
|
|
func MigratePaymentsKVToSQL(ctx context.Context, kvBackend kvdb.Backend,
|
|
sqlDB SQLMigrationQueries, cfg *SQLStoreConfig) error {
|
|
|
|
if cfg == nil {
|
|
return fmt.Errorf("missing SQL store config for migration")
|
|
}
|
|
|
|
if cfg.QueryCfg == nil {
|
|
return fmt.Errorf("missing SQL store config for " +
|
|
"validation")
|
|
}
|
|
|
|
if cfg.QueryCfg.MaxBatchSize == 0 {
|
|
return fmt.Errorf("invalid max batch size for " +
|
|
"validation")
|
|
}
|
|
|
|
stats := &MigrationStats{}
|
|
startTime := time.Now()
|
|
|
|
log.Infof("Starting payment migration from KV to SQL...")
|
|
|
|
var (
|
|
validationBatch []migratedPaymentRef
|
|
validatedPayments int64
|
|
|
|
reportInterval = rate.Sometimes{Interval: 5 * time.Second}
|
|
validationInterval = rate.Sometimes{Interval: 5 * time.Second}
|
|
)
|
|
|
|
// Open the KV backend in read-only mode.
|
|
err := kvBackend.View(func(kvTx kvdb.RTx) error {
|
|
// In case we start with an empty database, there are no
|
|
// payments to migrate.
|
|
paymentsBucket := kvTx.ReadBucket(paymentsRootBucket)
|
|
if paymentsBucket == nil {
|
|
log.Infof("No payments bucket found - database is " +
|
|
"empty")
|
|
|
|
return nil
|
|
}
|
|
|
|
// The index bucket maps sequence number -> payment hash.
|
|
indexes := kvTx.ReadBucket(paymentsIndexBucket)
|
|
if indexes == nil {
|
|
return fmt.Errorf("index bucket does not exist")
|
|
}
|
|
|
|
// We iterate over all sequence numbers in the index bucket to
|
|
// make sure we have the correct order of payments. Otherwise,
|
|
// if we just loop over the payments bucket, we might get the
|
|
// payments not in the chronological order but rather the
|
|
// lexicographical order of the payment hashes.
|
|
return indexes.ForEach(func(seqKey, indexVal []byte) error {
|
|
// Progress reporting based on time + actual work done.
|
|
reportProgress := func() {
|
|
elapsed := time.Since(startTime)
|
|
if elapsed <= 0 {
|
|
return
|
|
}
|
|
|
|
if stats.TotalPayments == 0 {
|
|
return
|
|
}
|
|
|
|
paymentRate := float64(stats.TotalPayments) /
|
|
elapsed.Seconds()
|
|
attemptRate := float64(stats.TotalAttempts) /
|
|
elapsed.Seconds()
|
|
|
|
log.Infof("Progress: %d payments, %d "+
|
|
"attempts, %d hops | Rate: %.1f "+
|
|
"pmt/s, %.1f att/s | Elapsed: %v",
|
|
stats.TotalPayments,
|
|
stats.TotalAttempts, stats.TotalHops,
|
|
paymentRate, attemptRate,
|
|
elapsed.Round(time.Second),
|
|
)
|
|
}
|
|
|
|
reportInterval.Do(reportProgress)
|
|
|
|
r := bytes.NewReader(indexVal)
|
|
paymentHash, err := deserializePaymentIndex(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
paymentBucket := paymentsBucket.NestedReadBucket(
|
|
paymentHash[:],
|
|
)
|
|
if paymentBucket == nil {
|
|
// We skip the entry in case this sequence
|
|
// number does not have a corresponding
|
|
// payment bucket. But aborting would not help
|
|
// either because it is just a db inconsistency.
|
|
log.Warnf("Missing bucket for payment %x",
|
|
paymentHash[:8])
|
|
|
|
stats.SkippedPayments++
|
|
|
|
return nil
|
|
}
|
|
|
|
// Every payment bucket should have a sequence number
|
|
// which is also important to check for duplicates.
|
|
seqBytes := paymentBucket.Get(paymentSequenceKey)
|
|
if seqBytes == nil {
|
|
return ErrNoSequenceNumber
|
|
}
|
|
|
|
// Skip duplicates. They are migrated into the
|
|
// payment_duplicates table when the primary payment is
|
|
// processed.
|
|
if !bytes.Equal(seqBytes, seqKey) {
|
|
return nil
|
|
}
|
|
|
|
// Fetch the payment from the kv store.
|
|
payment, err := fetchPayment(paymentBucket)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch payment %x: %w",
|
|
paymentHash[:8], err)
|
|
}
|
|
|
|
// Migrate the payment to the SQL database.
|
|
paymentID, err := migratePayment(
|
|
ctx, payment, paymentHash, sqlDB, stats,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("migrate payment %x: %w",
|
|
paymentHash[:8], err)
|
|
}
|
|
|
|
// Check for duplicates.
|
|
dupBucket := paymentBucket.NestedReadBucket(
|
|
duplicatePaymentsBucket,
|
|
)
|
|
if dupBucket != nil {
|
|
err = migrateDuplicatePayments(
|
|
ctx, dupBucket, paymentHash,
|
|
paymentID, sqlDB, stats,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("migrate duplicates "+
|
|
"%x: %w", paymentHash[:8],
|
|
err)
|
|
}
|
|
}
|
|
|
|
// Add the payment to the validation batch.
|
|
validationBatch = append(
|
|
validationBatch, migratedPaymentRef{
|
|
Hash: paymentHash,
|
|
PaymentID: paymentID,
|
|
},
|
|
)
|
|
if uint32(len(validationBatch)) >=
|
|
cfg.QueryCfg.MaxBatchSize {
|
|
|
|
err := validateMigratedPaymentBatch(
|
|
ctx, kvBackend, sqlDB,
|
|
cfg,
|
|
validationBatch,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
validatedPayments += int64(
|
|
len(validationBatch),
|
|
)
|
|
|
|
// Log validation progress periodically.
|
|
validationInterval.Do(func() {
|
|
log.Infof("Validated %d/%d "+
|
|
"payments",
|
|
validatedPayments,
|
|
stats.TotalPayments,
|
|
)
|
|
})
|
|
|
|
validationBatch = validationBatch[:0]
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}, func() {})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("migrate payments: %w", err)
|
|
}
|
|
|
|
// Validate any remaining payments in the batch.
|
|
if len(validationBatch) > 0 {
|
|
if err := validateMigratedPaymentBatch(
|
|
ctx, kvBackend, sqlDB, cfg, validationBatch,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
validatedPayments += int64(len(validationBatch))
|
|
log.Infof("Validated %d/%d payments", validatedPayments,
|
|
stats.TotalPayments)
|
|
}
|
|
|
|
// Validate the total number of payments as an additional sanity check.
|
|
if err := validatePaymentCounts(
|
|
ctx, sqlDB, stats.TotalPayments,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
stats.MigrationDuration = time.Since(startTime)
|
|
|
|
printMigrationSummary(stats)
|
|
|
|
return nil
|
|
}
|
|
|
|
// normalizeTimeForSQL converts a timestamp into the representation we persist
|
|
// and compare against in SQL:
|
|
// - drops any monotonic clock reading (SQL can't store it),
|
|
// - forces UTC for deterministic comparisons across environments.
|
|
//
|
|
// A zero time is returned unchanged.
|
|
func normalizeTimeForSQL(t time.Time) time.Time {
|
|
if t.IsZero() {
|
|
return t
|
|
}
|
|
|
|
return time.Unix(0, t.UnixNano()).UTC()
|
|
}
|
|
|
|
// migratePayment migrates a single payment from KV to SQL.
|
|
func migratePayment(ctx context.Context, payment *MPPayment, hash lntypes.Hash,
|
|
sqlDB SQLMigrationQueries, stats *MigrationStats) (int64, error) {
|
|
|
|
// Update migration stats based on payment status.
|
|
switch payment.Status {
|
|
case StatusSucceeded:
|
|
stats.SuccessfulPayments++
|
|
|
|
case StatusFailed:
|
|
stats.FailedPayments++
|
|
|
|
case StatusInFlight:
|
|
stats.InFlightPayments++
|
|
|
|
case StatusInitiated:
|
|
stats.InitiatedPayments++
|
|
}
|
|
|
|
// Prepare fail reason for SQL insert.
|
|
var failReason sql.NullInt32
|
|
if payment.FailureReason != nil {
|
|
failReason = sql.NullInt32{
|
|
Int32: int32(*payment.FailureReason),
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
// Insert payment using migration query.
|
|
paymentID, err := sqlDB.InsertPaymentMig(
|
|
ctx, sqlc.InsertPaymentMigParams{
|
|
AmountMsat: int64(payment.Info.Value),
|
|
CreatedAt: normalizeTimeForSQL(
|
|
payment.Info.CreationTime,
|
|
),
|
|
PaymentIdentifier: hash[:],
|
|
FailReason: failReason,
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("insert payment: %w", err)
|
|
}
|
|
|
|
// Insert payment intent.
|
|
//
|
|
// Only insert a row if we have an actual intent payload. For legacy
|
|
// hash-only/keysend-style payments, the intent may be absent.
|
|
if len(payment.Info.PaymentRequest) > 0 {
|
|
_, err = sqlDB.InsertPaymentIntent(
|
|
ctx, sqlc.InsertPaymentIntentParams{
|
|
PaymentID: paymentID,
|
|
IntentType: int16(PaymentIntentTypeBolt11),
|
|
IntentPayload: payment.Info.PaymentRequest,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("insert intent: %w", err)
|
|
}
|
|
}
|
|
|
|
// Insert first hop custom records (payment level).
|
|
for key, value := range payment.Info.FirstHopCustomRecords {
|
|
err = sqlDB.InsertPaymentFirstHopCustomRecord(ctx,
|
|
sqlc.InsertPaymentFirstHopCustomRecordParams{
|
|
PaymentID: paymentID,
|
|
Key: int64(key),
|
|
Value: value,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("insert custom record: %w", err)
|
|
}
|
|
}
|
|
|
|
// Migrate HTLC attempts.
|
|
for _, htlc := range payment.HTLCs {
|
|
err = migrateHTLCAttempt(
|
|
ctx, paymentID, hash, &htlc, sqlDB, stats,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("migrate attempt %d: %w",
|
|
htlc.AttemptID, err)
|
|
}
|
|
}
|
|
|
|
stats.TotalPayments++
|
|
|
|
return paymentID, nil
|
|
}
|
|
|
|
// migrateHTLCAttempt migrates a single HTLC attempt.
|
|
func migrateHTLCAttempt(ctx context.Context, paymentID int64,
|
|
parentPaymentHash lntypes.Hash, htlc *HTLCAttempt, sqlDB SQLQueries,
|
|
stats *MigrationStats) error {
|
|
|
|
// Determine the payment hash for this HTLC attempt.
|
|
//
|
|
// For AMP payments, each HTLC has its own unique hash. For non-AMP
|
|
// payments (MPP, Legacy), all HTLCs use the same hash as the parent
|
|
// payment. Older payment attempts may not have the hash stored
|
|
// explicitly, in which case we fall back to the parent payment hash
|
|
// which is ok since non-AMP payments have a single hash for all HTLCs.
|
|
var paymentHash []byte
|
|
switch {
|
|
case htlc.Hash != nil:
|
|
paymentHash = (*htlc.Hash)[:]
|
|
|
|
default:
|
|
// For older payments where Hash is nil, use the parent payment
|
|
// hash. This is consistent with how the router handles these
|
|
// legacy payments.
|
|
paymentHash = parentPaymentHash[:]
|
|
}
|
|
|
|
firstHopAmountMsat := int64(htlc.Route.FirstHopAmount.Val.Int())
|
|
|
|
// Get the session key bytes.
|
|
sessionKeyBytes := htlc.SessionKey().Serialize()
|
|
|
|
// Insert HTLC attempt.
|
|
_, err := sqlDB.InsertHtlcAttempt(ctx, sqlc.InsertHtlcAttemptParams{
|
|
PaymentID: paymentID,
|
|
AttemptIndex: int64(htlc.AttemptID),
|
|
SessionKey: sessionKeyBytes,
|
|
AttemptTime: normalizeTimeForSQL(htlc.AttemptTime),
|
|
PaymentHash: paymentHash,
|
|
FirstHopAmountMsat: firstHopAmountMsat,
|
|
RouteTotalTimeLock: int32(htlc.Route.TotalTimeLock),
|
|
RouteTotalAmount: int64(htlc.Route.TotalAmount),
|
|
RouteSourceKey: htlc.Route.SourcePubKey[:],
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("insert HTLC attempt: %w", err)
|
|
}
|
|
|
|
// Insert the route-level first hop custom records.
|
|
for key, value := range htlc.Route.FirstHopWireCustomRecords {
|
|
err = sqlDB.InsertPaymentAttemptFirstHopCustomRecord(
|
|
ctx,
|
|
sqlc.InsertPaymentAttemptFirstHopCustomRecordParams{
|
|
HtlcAttemptIndex: int64(htlc.AttemptID),
|
|
Key: int64(key),
|
|
Value: value,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert attempt first hop custom "+
|
|
"record: %w", err)
|
|
}
|
|
}
|
|
|
|
// Insert route hops.
|
|
for hopIndex := range htlc.Route.Hops {
|
|
hop := htlc.Route.Hops[hopIndex]
|
|
err = migrateRouteHop(
|
|
ctx, int64(htlc.AttemptID), hopIndex, hop,
|
|
sqlDB, stats,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("migrate hop %d: %w", hopIndex, err)
|
|
}
|
|
}
|
|
|
|
// Handle attempt resolution (settle or fail).
|
|
switch {
|
|
case htlc.Settle != nil:
|
|
// Settled
|
|
err = sqlDB.SettleAttempt(ctx, sqlc.SettleAttemptParams{
|
|
AttemptIndex: int64(htlc.AttemptID),
|
|
ResolutionTime: normalizeTimeForSQL(
|
|
htlc.Settle.SettleTime,
|
|
),
|
|
ResolutionType: int32(HTLCAttemptResolutionSettled),
|
|
SettlePreimage: htlc.Settle.Preimage[:],
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("settle attempt: %w", err)
|
|
}
|
|
|
|
stats.SettledAttempts++
|
|
|
|
case htlc.Failure != nil:
|
|
var failureMsg bytes.Buffer
|
|
if htlc.Failure.Message != nil {
|
|
err := lnwire.EncodeFailureMessage(
|
|
&failureMsg, htlc.Failure.Message, 0,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encode "+
|
|
"failure message: %w", err)
|
|
}
|
|
}
|
|
|
|
err = sqlDB.FailAttempt(ctx, sqlc.FailAttemptParams{
|
|
AttemptIndex: int64(htlc.AttemptID),
|
|
ResolutionTime: normalizeTimeForSQL(
|
|
htlc.Failure.FailTime,
|
|
),
|
|
ResolutionType: int32(HTLCAttemptResolutionFailed),
|
|
FailureSourceIndex: sql.NullInt32{
|
|
Int32: int32(htlc.Failure.FailureSourceIndex),
|
|
Valid: true,
|
|
},
|
|
HtlcFailReason: sql.NullInt32{
|
|
Int32: int32(htlc.Failure.Reason),
|
|
Valid: true,
|
|
},
|
|
FailureMsg: failureMsg.Bytes(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("fail attempt: %w", err)
|
|
}
|
|
|
|
stats.FailedAttempts++
|
|
|
|
default:
|
|
// If the attempt is not settled or failed, it is in flight.
|
|
stats.InFlightAttempts++
|
|
}
|
|
|
|
stats.TotalAttempts++
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateRouteHop migrates a single route hop.
|
|
func migrateRouteHop(ctx context.Context,
|
|
attemptID int64, hopIndex int, hop *Hop, sqlDB SQLQueries,
|
|
stats *MigrationStats) error {
|
|
|
|
// Convert channel ID to string representation of uint64.
|
|
// The SCID is stored as a decimal string to match the converter
|
|
// expectations (sql_converters.go:173).
|
|
scidStr := strconv.FormatUint(hop.ChannelID, 10)
|
|
|
|
// Insert route hop.
|
|
hopID, err := sqlDB.InsertRouteHop(ctx, sqlc.InsertRouteHopParams{
|
|
HtlcAttemptIndex: attemptID,
|
|
HopIndex: int32(hopIndex),
|
|
PubKey: hop.PubKeyBytes[:],
|
|
Scid: scidStr,
|
|
OutgoingTimeLock: int32(hop.OutgoingTimeLock),
|
|
AmtToForward: int64(hop.AmtToForward),
|
|
MetaData: hop.Metadata,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("insert hop: %w", err)
|
|
}
|
|
|
|
// Check for blinded route data (route blinding).
|
|
if len(hop.EncryptedData) > 0 || hop.BlindingPoint != nil ||
|
|
hop.TotalAmtMsat != 0 {
|
|
|
|
var blindingPoint []byte
|
|
if hop.BlindingPoint != nil {
|
|
blindingPoint = hop.BlindingPoint.SerializeCompressed()
|
|
}
|
|
|
|
var totalAmt sql.NullInt64
|
|
if hop.TotalAmtMsat != 0 {
|
|
totalAmt = sql.NullInt64{
|
|
Int64: int64(hop.TotalAmtMsat),
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
err := sqlDB.InsertRouteHopBlinded(
|
|
ctx, sqlc.InsertRouteHopBlindedParams{
|
|
HopID: hopID,
|
|
EncryptedData: hop.EncryptedData,
|
|
BlindingPoint: blindingPoint,
|
|
BlindedPathTotalAmt: totalAmt,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert blinded hop: %w", err)
|
|
}
|
|
}
|
|
|
|
// Check for MPP record.
|
|
if hop.MPP != nil {
|
|
paymentAddr := hop.MPP.PaymentAddr()
|
|
err = sqlDB.InsertRouteHopMpp(ctx, sqlc.InsertRouteHopMppParams{
|
|
HopID: hopID,
|
|
PaymentAddr: paymentAddr[:],
|
|
TotalMsat: int64(hop.MPP.TotalMsat()),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("insert MPP: %w", err)
|
|
}
|
|
}
|
|
|
|
// Check for AMP record.
|
|
if hop.AMP != nil {
|
|
rootShare := hop.AMP.RootShare()
|
|
setID := hop.AMP.SetID()
|
|
err = sqlDB.InsertRouteHopAmp(ctx, sqlc.InsertRouteHopAmpParams{
|
|
HopID: hopID,
|
|
RootShare: rootShare[:],
|
|
SetID: setID[:],
|
|
ChildIndex: int32(hop.AMP.ChildIndex()),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("insert AMP: %w", err)
|
|
}
|
|
}
|
|
|
|
// Check for custom records.
|
|
if hop.CustomRecords != nil {
|
|
for tlvType, value := range hop.CustomRecords {
|
|
err = sqlDB.InsertPaymentHopCustomRecord(
|
|
ctx,
|
|
sqlc.InsertPaymentHopCustomRecordParams{
|
|
HopID: hopID,
|
|
Key: int64(tlvType),
|
|
Value: value,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert hop custom "+
|
|
"record: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
stats.TotalHops++
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateDuplicatePayments migrates duplicate payments into the dedicated
|
|
// payment_duplicates table.
|
|
func migrateDuplicatePayments(ctx context.Context, dupBucket kvdb.RBucket,
|
|
hash [32]byte, primaryPaymentID int64, sqlDB SQLMigrationQueries,
|
|
stats *MigrationStats) error {
|
|
|
|
duplicateCount := 0
|
|
|
|
err := dupBucket.ForEach(func(seqBytes, _ []byte) error {
|
|
// The duplicates bucket should only contain nested buckets
|
|
// keyed by 8-byte sequence numbers. Skip any unexpected keys
|
|
// (defensive check for corrupted or malformed data).
|
|
if len(seqBytes) != 8 {
|
|
log.Warnf("Skipping unexpected key in duplicates "+
|
|
"bucket for payment %x: key length %d, "+
|
|
"expected 8",
|
|
hash[:8], len(seqBytes),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
seqNum := byteOrder.Uint64(seqBytes)
|
|
subBucket := dupBucket.NestedReadBucket(seqBytes)
|
|
if subBucket == nil {
|
|
return nil
|
|
}
|
|
|
|
duplicateCount++
|
|
log.Infof("Migrating duplicate payment seq=%d for "+
|
|
"payment %x", seqNum, hash[:8])
|
|
|
|
err := migrateSingleDuplicatePayment(
|
|
ctx, subBucket, hash, primaryPaymentID, seqNum,
|
|
sqlDB,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"migrate duplicate payment seq=%d: %w",
|
|
seqNum, err,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if duplicateCount > 0 {
|
|
stats.DuplicatePayments++
|
|
stats.DuplicateEntries += int64(duplicateCount)
|
|
|
|
log.Infof("Payment %x had %d duplicate(s) migrated", hash[:8],
|
|
duplicateCount)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// migrateSingleDuplicatePayment inserts a duplicate payment record for the
|
|
// given payment hash into payment_duplicates.
|
|
func migrateSingleDuplicatePayment(ctx context.Context, dupBucket kvdb.RBucket,
|
|
hash [32]byte, primaryPaymentID int64, duplicateSeq uint64,
|
|
sqlDB SQLMigrationQueries) error {
|
|
|
|
creationData := dupBucket.Get(duplicatePaymentCreationInfoKey)
|
|
if creationData == nil {
|
|
return fmt.Errorf("duplicate payment seq=%d missing "+
|
|
"creation info (payment=%x)", duplicateSeq, hash[:8])
|
|
}
|
|
|
|
creationInfo, err := deserializeDuplicatePaymentCreationInfo(
|
|
bytes.NewReader(creationData),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("deserialize duplicate creation "+
|
|
"info: %w", err)
|
|
}
|
|
|
|
settleData := dupBucket.Get(duplicatePaymentSettleInfoKey)
|
|
failReasonData := dupBucket.Get(duplicatePaymentFailInfoKey)
|
|
attemptData := dupBucket.Get(duplicatePaymentAttemptInfoKey)
|
|
|
|
if settleData != nil && len(failReasonData) > 0 {
|
|
return fmt.Errorf("duplicate payment seq=%d has both "+
|
|
"settle and fail info (payment=%x)", duplicateSeq,
|
|
hash[:8])
|
|
}
|
|
|
|
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 payment has no settle or fail info,
|
|
// we mark it as failed during the migration. Duplicate
|
|
// payments were a bug in older versions of LND, so we can be
|
|
// sure if a duplicate payment has no failure reason or
|
|
// settlement data, the corresponding HTLC for this payment
|
|
// has been failed (resolved).
|
|
if attemptData == nil {
|
|
log.Warnf("Duplicate payment seq=%d has no "+
|
|
"attempt info and no resolution (payment=%x); "+
|
|
"marking failed", duplicateSeq, hash[:8])
|
|
} else {
|
|
log.Warnf("Duplicate payment seq=%d has attempt "+
|
|
"info but no resolution (payment=%x); "+
|
|
"marking failed", duplicateSeq, hash[:8])
|
|
}
|
|
|
|
failReason = sql.NullInt32{
|
|
Int32: int32(FailureReasonError),
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
_, err = sqlDB.InsertPaymentDuplicateMig(
|
|
ctx, sqlc.InsertPaymentDuplicateMigParams{
|
|
PaymentID: primaryPaymentID,
|
|
AmountMsat: int64(creationInfo.Value),
|
|
CreatedAt: normalizeTimeForSQL(
|
|
creationInfo.CreationTime,
|
|
),
|
|
FailReason: failReason,
|
|
SettlePreimage: settlePreimage,
|
|
SettleTime: settleTime,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert duplicate payment: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseDuplicateSettleData extracts settle data from either legacy or modern
|
|
// duplicate formats.
|
|
func parseDuplicateSettleData(settleData []byte) ([]byte, sql.NullTime, error) {
|
|
if len(settleData) == lntypes.PreimageSize {
|
|
return append([]byte(nil), settleData...), sql.NullTime{}, nil
|
|
}
|
|
|
|
settleInfo, err := deserializeHTLCSettleInfo(
|
|
bytes.NewReader(settleData),
|
|
)
|
|
if err != nil {
|
|
return nil, sql.NullTime{},
|
|
fmt.Errorf("deserialize duplicate settle: %w", err)
|
|
}
|
|
|
|
settleTime := normalizeTimeForSQL(settleInfo.SettleTime)
|
|
|
|
return settleInfo.Preimage[:], sql.NullTime{
|
|
Time: settleTime,
|
|
Valid: !settleTime.IsZero(),
|
|
}, nil
|
|
}
|
|
|
|
// printMigrationSummary prints a summary of the migration.
|
|
func printMigrationSummary(stats *MigrationStats) {
|
|
if stats.TotalPayments == 0 {
|
|
log.Infof("No payments migrated - database is empty")
|
|
|
|
return
|
|
}
|
|
|
|
log.Infof("========================================")
|
|
log.Infof(" Payment Migration Summary")
|
|
log.Infof("========================================")
|
|
log.Infof("Total Payments: %d", stats.TotalPayments)
|
|
log.Infof(" Successful: %d", stats.SuccessfulPayments)
|
|
log.Infof(" Failed: %d", stats.FailedPayments)
|
|
log.Infof(" In-Flight: %d", stats.InFlightPayments)
|
|
log.Infof(" Initiated: %d", stats.InitiatedPayments)
|
|
log.Infof("")
|
|
log.Infof("Total HTLC Attempts: %d", stats.TotalAttempts)
|
|
log.Infof(" Settled: %d", stats.SettledAttempts)
|
|
log.Infof(" Failed: %d", stats.FailedAttempts)
|
|
log.Infof(" In-Flight: %d", stats.InFlightAttempts)
|
|
log.Infof("")
|
|
log.Infof("Total Route Hops: %d", stats.TotalHops)
|
|
|
|
if stats.SkippedPayments > 0 {
|
|
log.Infof("")
|
|
log.Warnf("SKIPPED PAYMENTS:")
|
|
log.Warnf(" Indexed payments with missing buckets: %d",
|
|
stats.SkippedPayments)
|
|
log.Warnf(" These indicate minor DB inconsistencies.")
|
|
}
|
|
|
|
if stats.DuplicatePayments > 0 {
|
|
log.Infof("")
|
|
log.Warnf("DUPLICATE PAYMENTS DETECTED:")
|
|
log.Warnf(" Unique payment hashes with duplicates: %d",
|
|
stats.DuplicatePayments)
|
|
log.Warnf(" Total duplicate entries migrated: %d",
|
|
stats.DuplicateEntries)
|
|
log.Warnf(" These were caused by an old LND bug.")
|
|
}
|
|
|
|
log.Infof("")
|
|
log.Infof("Migration Duration: %v", stats.MigrationDuration)
|
|
log.Infof("========================================")
|
|
}
|