paymentsdb: use non-terminal payment query for recovery
What changed, and why it matters
This commit fixes how LND's SQL payment store finds payments that are still in progress during startup recovery. Previously it scanned individual payment attempts that were still unresolved, which could miss or mishandle payments. Now it queries payments directly by their non-terminal status and loads only the related attempt data. The change is framed by the developer as fixing a regression in recovery behavior, with tests now passing on both database backends.
Treat as a bug-fix commit with potential reliability/security implications for payment state recovery. Review the associated regression tests and consider whether the old unresolved-attempt scan could have led to incorrect payment state on node restart. No immediate exploit mitigation is indicated, but operators should upgrade to a release containing this fix to ensure correct inflight payment recovery.
Security signals we found
Recovery-path logic change: affects how in-flight payments are reconstructed on restart
Regression-test fix: commit message says inflight recovery tests now pass on both KV and SQL backends
Data-consistency improvement: queries payments by status rather than by unresolved attempts, reducing risk of missing or duplicating payments during recovery
No explicit security advisory, CVE, or exploit details present in commit or references
Evidence from the diff
The patch rewrites FetchInFlightPayments in payments/db/sql_store.go to use the new FetchNonTerminalPayments query instead of FetchAllInflightAttempts. It removes per-attempt deduplication logic and a final sort, instead batch-loading payment details and route/attempt data only for the returned non-terminal payment IDs. The helper functions and data types are updated accordingly (paymentsCompleteData -> paymentsDetailsData, processAttempt -> processPayment). The commit message explicitly states this fixes inflight recovery regression tests for both KV and SQL stores.
Changed components
payments/db/sql_store.goFetchInFlightPaymentsSQL payment storeInflight payment recoveryInspect captured patch +22 / −63
diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go
index 321c4b9..960ad41 100644
--- a/payments/db/sql_store.go
+++ b/payments/db/sql_store.go
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"math"
- "sort"
"strconv"
"time"
@@ -1048,104 +1047,64 @@ func (s *SQLStore) FetchInFlightPayments(ctx context.Context) ([]*MPPayment,
var mpPayments []*MPPayment
err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error {
- // Track which payment IDs we've already processed across all
- // pages to avoid loading the same payment multiple times when
- // multiple inflight attempts belong to the same payment.
- processedPayments := make(map[int64]*MPPayment)
+ extractCursor := func(
+ row sqlc.FetchNonTerminalPaymentsRow) int64 {
- extractCursor := func(row sqlc.PaymentHtlcAttempt) int64 {
- return row.AttemptIndex
+ return row.ID
}
- // collectFunc extracts the payment ID from each attempt row.
- collectFunc := func(row sqlc.PaymentHtlcAttempt) (
+ collectFunc := func(row sqlc.FetchNonTerminalPaymentsRow) (
int64, error) {
- return row.PaymentID, nil
+ return row.ID, nil
}
- // batchDataFunc loads payment data for a batch of payment IDs,
- // but only for IDs we haven't processed yet.
batchDataFunc := func(ctx context.Context,
- paymentIDs []int64) (*paymentsCompleteData, error) {
-
- // Filter out already-processed payment IDs.
- uniqueIDs := make([]int64, 0, len(paymentIDs))
- for _, id := range paymentIDs {
- _, processed := processedPayments[id]
- if !processed {
- uniqueIDs = append(uniqueIDs, id)
- }
- }
+ paymentIDs []int64) (*paymentsDetailsData, error) {
- // If uniqueIDs is empty, the batch load will return
- // empty batch data.
- return batchLoadPayments(
- ctx, s.cfg.QueryCfg, db, uniqueIDs,
+ return batchLoadPaymentDetailsData(
+ ctx, s.cfg.QueryCfg, db, paymentIDs, true,
)
}
- // processAttempt processes each attempt. We only build and
- // store the payment once per unique payment ID.
- processAttempt := func(ctx context.Context,
- row sqlc.PaymentHtlcAttempt,
- batchData *paymentsCompleteData) error {
-
- // Skip if we've already processed this payment.
- _, processed := processedPayments[row.PaymentID]
- if processed {
- return nil
- }
-
- dbPayment := batchData.paymentsAndIntents[row.PaymentID]
+ processPayment := func(ctx context.Context,
+ row sqlc.FetchNonTerminalPaymentsRow,
+ batchData *paymentsDetailsData) error {
- // Build the payment from batch data.
- mpPayment, err := buildPaymentFromBatchData(
- dbPayment, batchData.paymentsDetailsData, true,
+ payment, err := buildPaymentFromBatchData(
+ row, batchData, true,
)
if err != nil {
return fmt.Errorf("failed to build payment: %w",
err)
}
- // Store in our processed map.
- processedPayments[row.PaymentID] = mpPayment
+ mpPayments = append(mpPayments, payment)
return nil
}
- queryFunc := func(ctx context.Context, lastAttemptIndex int64,
- limit int32) ([]sqlc.PaymentHtlcAttempt,
+ queryFunc := func(ctx context.Context, lastPaymentID int64,
+ limit int32) ([]sqlc.FetchNonTerminalPaymentsRow,
error) {
- return db.FetchAllInflightAttempts(ctx,
- sqlc.FetchAllInflightAttemptsParams{
- AttemptIndex: lastAttemptIndex,
- Limit: limit,
+ return db.FetchNonTerminalPayments(ctx,
+ sqlc.FetchNonTerminalPaymentsParams{
+ ID: lastPaymentID,
+ Limit: limit,
},
)
}
err := sqldb.ExecuteCollectAndBatchWithSharedDataQuery(
- ctx, s.cfg.QueryCfg, int64(-1), queryFunc,
+ ctx, s.cfg.QueryCfg, int64(0), queryFunc,
extractCursor, collectFunc, batchDataFunc,
- processAttempt,
+ processPayment,
)
if err != nil {
return err
}
- // Convert map to slice and sort by sequence number to
- // produce a deterministic ordering.
- mpPayments = make([]*MPPayment, 0, len(processedPayments))
- for _, payment := range processedPayments {
- mpPayments = append(mpPayments, payment)
- }
- sort.Slice(mpPayments, func(i, j int) bool {
- return mpPayments[i].SequenceNum <
- mpPayments[j].SequenceNum
- })
-
return nil
}, func() {
mpPayments = nil
Why this scored 30/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.