From 3012eb2ca096a596f9b91935a30ae7382b970360 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 15 Oct 2025 16:49:15 +0200 Subject: [PATCH] paymentsdb: implement DeleteFailedAttempts for sql backend --- payments/db/kv_store.go | 2 + payments/db/sql_store.go | 120 +++++++++++++++++++++++++++++++++++++ sqldb/sqlc/payments.sql.go | 2 + sqldb/sqlc/querier.go | 2 + 4 files changed, 126 insertions(+) diff --git a/payments/db/kv_store.go b/payments/db/kv_store.go index 62f0b8386..84946841b 100644 --- a/payments/db/kv_store.go +++ b/payments/db/kv_store.go @@ -291,6 +291,8 @@ func (p *KVStore) InitPayment(paymentHash lntypes.Hash, // DeleteFailedAttempts deletes all failed htlcs for a payment if configured // by the KVStore db. func (p *KVStore) DeleteFailedAttempts(hash lntypes.Hash) error { + // TODO(ziggie): Refactor to not mix application logic with database + // logic. This decision should be made in the application layer. if !p.keepFailedPaymentAttempts { const failedHtlcsOnly = true err := p.DeletePayment(hash, failedHtlcsOnly) diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index d2500d62a..5f22f47a5 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -712,3 +712,123 @@ func (s *SQLStore) FetchPayment(paymentHash lntypes.Hash) (*MPPayment, error) { return mpPayment, nil } + +// DeleteFailedAttempts removes all failed HTLC attempts from the database for +// the specified payment, while preserving the payment record itself and any +// successful or in-flight attempts. +// +// The method performs the following validations before deletion: +// - StatusInitiated: Can delete failed attempts +// - StatusInFlight: Cannot delete, returns ErrPaymentInFlight (active HTLCs +// still on the network) +// - StatusSucceeded: Can delete failed attempts (payment completed) +// - StatusFailed: Can delete failed attempts (payment permanently failed) +// +// If the keepFailedPaymentAttempts configuration flag is enabled, this method +// returns immediately without deleting anything, allowing failed attempts to +// be retained for debugging or auditing purposes. +// +// This method is idempotent - calling it multiple times on the same payment +// has no adverse effects. +// +// This method is part of the PaymentControl interface, which is embedded in +// the PaymentWriter interface and ultimately the DB interface. It represents +// the final step (step 5) in the payment lifecycle control flow and should be +// called after a payment reaches a terminal state (succeeded or permanently +// failed) to clean up historical failed attempts. +func (s *SQLStore) DeleteFailedAttempts(paymentHash lntypes.Hash) error { + ctx := context.TODO() + + // In case we are configured to keep failed payment attempts, we exit + // early. + // + // TODO(ziggie): Refactor to not mix application logic with database + // logic. This decision should be made in the application layer. + if s.keepFailedPaymentAttempts { + return nil + } + + err := s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + dbPayment, err := db.FetchPayment(ctx, paymentHash[:]) + if err != nil { + return fmt.Errorf("failed to fetch payment: %w", err) + } + + paymentStatus, err := computePaymentStatusFromDB( + ctx, db, dbPayment, + ) + if err != nil { + return fmt.Errorf("failed to compute payment "+ + "status: %w", err) + } + + if err := paymentStatus.removable(); err != nil { + return fmt.Errorf("cannot delete failed "+ + "attempts for payment %v: %w", paymentHash, err) + } + + // Then we delete the failed attempts for this payment. + return db.DeleteFailedAttempts(ctx, dbPayment.Payment.ID) + }, sqldb.NoOpReset) + if err != nil { + return fmt.Errorf("failed to delete failed attempts for "+ + "payment %v: %w", paymentHash, err) + } + + return nil +} + +// computePaymentStatusFromDB computes the payment status by fetching minimal +// data from the database. This is a lightweight query optimized for SQL that +// doesn't load route data, making it significantly more efficient than +// FetchPayment when only the status is needed. +func computePaymentStatusFromDB(ctx context.Context, db SQLQueries, + dbPayment sqlc.PaymentAndIntent) (PaymentStatus, error) { + + payment := dbPayment.GetPayment() + + resolutionTypes, err := db.FetchHtlcAttemptResolutionsForPayment( + ctx, payment.ID, + ) + if err != nil { + return 0, fmt.Errorf("failed to fetch htlc resolutions: %w", + err) + } + + // Build minimal HTLCAttempt slice with only resolution info. + htlcs := make([]HTLCAttempt, len(resolutionTypes)) + for i, resType := range resolutionTypes { + if !resType.Valid { + // NULL resolution_type means in-flight (no Settle, no + // Failure). + continue + } + + switch HTLCAttemptResolutionType(resType.Int32) { + case HTLCAttemptResolutionSettled: + // Mark as settled (preimage details not needed for + // status). + htlcs[i].Settle = &HTLCSettleInfo{} + + case HTLCAttemptResolutionFailed: + // Mark as failed (failure details not needed for + // status). + htlcs[i].Failure = &HTLCFailInfo{} + } + } + + // Convert fail reason to FailureReason pointer. + var failureReason *FailureReason + if payment.FailReason.Valid { + reason := FailureReason(payment.FailReason.Int32) + failureReason = &reason + } + + // Use the existing status decision logic. + status, err := decidePaymentStatus(htlcs, failureReason) + if err != nil { + return 0, fmt.Errorf("failed to decide payment status: %w", err) + } + + return status, nil +} diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index dd135e3db..0883023a9 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -29,6 +29,8 @@ DELETE FROM payment_htlc_attempts WHERE payment_id = $1 AND attempt_index IN ( ) ` +// Delete all failed HTLC attempts for the given payment. Resolution type 2 +// indicates a failed attempt. func (q *Queries) DeleteFailedAttempts(ctx context.Context, paymentID int64) error { _, err := q.db.ExecContext(ctx, deleteFailedAttempts, paymentID) return err diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 008624af4..9ba6f66a7 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -22,6 +22,8 @@ type Querier interface { DeleteChannelPolicyExtraTypes(ctx context.Context, channelPolicyID int64) error DeleteChannels(ctx context.Context, ids []int64) error DeleteExtraNodeType(ctx context.Context, arg DeleteExtraNodeTypeParams) error + // Delete all failed HTLC attempts for the given payment. Resolution type 2 + // indicates a failed attempt. DeleteFailedAttempts(ctx context.Context, paymentID int64) error DeleteInvoice(ctx context.Context, arg DeleteInvoiceParams) (sql.Result, error) DeleteNode(ctx context.Context, id int64) error