diff --git a/payments/db/payment_test.go b/payments/db/payment_test.go index 55a5c0966e3..e304b136dd2 100644 --- a/payments/db/payment_test.go +++ b/payments/db/payment_test.go @@ -3008,6 +3008,80 @@ func TestFetchInFlightPaymentsMultipleAttempts(t *testing.T) { require.Len(t, inFlightPayments[0].HTLCs, 2) } +// TestFetchInFlightPaymentsIncludesRetryablePayments tests that payments with +// only failed HTLCs but no payment-level failure reason are still returned as +// in-flight. This matches the shared payment state machine used by the router. +func TestFetchInFlightPaymentsIncludesRetryablePayments(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + attempt := genAttemptWithHash(t, 0, genSessionKey(t), rhash) + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + _, err = paymentDB.FailAttempt( + ctx, info.PaymentIdentifier, attempt.AttemptID, + &HTLCFailInfo{Reason: HTLCFailUnreadable}, + ) + require.NoError(t, err) + + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) + require.Equal(t, StatusInFlight, payment.Status) + + inFlightPayments, err := paymentDB.FetchInFlightPayments(ctx) + require.NoError(t, err) + + inFlightHashes := make(map[lntypes.Hash]struct{}, len(inFlightPayments)) + for _, p := range inFlightPayments { + inFlightHashes[p.Info.PaymentIdentifier] = struct{}{} + } + + require.Contains(t, inFlightHashes, info.PaymentIdentifier) +} + +// TestFetchInFlightPaymentsIncludesInitiatedPayments tests that payments which +// have been initialized but have not yet registered an HTLC are still returned +// as non-terminal payments. +func TestFetchInFlightPaymentsIncludesInitiatedPayments(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + preimg := genPreimage(t) + rhash := sha256.Sum256(preimg[:]) + info := genPaymentCreationInfo(t, rhash) + + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) + require.Equal(t, StatusInitiated, payment.Status) + + inFlightPayments, err := paymentDB.FetchInFlightPayments(ctx) + require.NoError(t, err) + + inFlightHashes := make(map[lntypes.Hash]struct{}, len(inFlightPayments)) + for _, p := range inFlightPayments { + inFlightHashes[p.Info.PaymentIdentifier] = struct{}{} + } + + require.Contains(t, inFlightHashes, info.PaymentIdentifier) +} + // TestRouteFirstHopData tests that Route.FirstHopAmount and // Route.FirstHopWireCustomRecords are correctly stored and retrieved. func TestRouteFirstHopData(t *testing.T) { @@ -3118,6 +3192,41 @@ func TestRegisterAttemptWithAMP(t *testing.T) { require.Equal(t, childIndex, finalHop.AMP.ChildIndex()) } +// TestRegisterAttemptPreservesAttemptHash tests that an attempt's own hash is +// preserved independently from the payment identifier. This is especially +// important for AMP payments where the payment identifier is the SetID and the +// individual HTLC attempts each use their own payment hash. +func TestRegisterAttemptPreservesAttemptHash(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + paymentDB, _ := NewTestDB(t) + + setID := lntypes.Hash{1, 2, 3, 4} + attemptHash := lntypes.Hash{5, 6, 7, 8} + info := genPaymentCreationInfo(t, setID) + + err := paymentDB.InitPayment(ctx, info.PaymentIdentifier, info) + require.NoError(t, err) + + attempt := genAttemptWithHash(t, 0, genSessionKey(t), attemptHash) + finalHopIdx := len(attempt.Route.Hops) - 1 + attempt.Route.Hops[finalHopIdx].AMP = record.NewAMP( + [32]byte{9, 10, 11, 12}, setID, 99, + ) + + _, err = paymentDB.RegisterAttempt(ctx, info.PaymentIdentifier, attempt) + require.NoError(t, err) + + payment, err := paymentDB.FetchPayment(ctx, info.PaymentIdentifier) + require.NoError(t, err) + require.Len(t, payment.HTLCs, 1) + require.NotNil(t, payment.HTLCs[0].Hash) + require.Equal(t, attemptHash, *payment.HTLCs[0].Hash) + require.NotEqual(t, info.PaymentIdentifier, *payment.HTLCs[0].Hash) +} + // TestRegisterAttemptWithBlindedRoute tests that blinded route data // (EncryptedData, BlindingPoint, TotalAmtMsat) is correctly stored and // retrieved. diff --git a/payments/db/sql_store.go b/payments/db/sql_store.go index 5726504b57d..3d92385fd0f 100644 --- a/payments/db/sql_store.go +++ b/payments/db/sql_store.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "math" - "sort" "strconv" "time" @@ -50,12 +49,12 @@ type SQLQueries interface { FilterPaymentsDesc(ctx context.Context, query sqlc.FilterPaymentsDescParams) ([]sqlc.FilterPaymentsDescRow, error) FetchPayment(ctx context.Context, paymentIdentifier []byte) (sqlc.FetchPaymentRow, error) FetchPaymentsByIDs(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchPaymentsByIDsRow, error) + FetchNonTerminalPayments(ctx context.Context, arg sqlc.FetchNonTerminalPaymentsParams) ([]sqlc.FetchNonTerminalPaymentsRow, error) CountPayments(ctx context.Context) (int64, error) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptsForPaymentsRow, error) FetchHtlcAttemptResolutionsForPayments(ctx context.Context, paymentIDs []int64) ([]sqlc.FetchHtlcAttemptResolutionsForPaymentsRow, error) - FetchAllInflightAttempts(ctx context.Context, arg sqlc.FetchAllInflightAttemptsParams) ([]sqlc.PaymentHtlcAttempt, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]sqlc.FetchHopsForAttemptsRow, error) FetchPaymentDuplicates(ctx context.Context, paymentID int64) ([]sqlc.PaymentDuplicate, error) @@ -182,88 +181,6 @@ func fetchPaymentWithCompleteData(ctx context.Context, return buildPaymentFromBatchData(dbPayment, batchData, true) } -// paymentsCompleteData holds the full payment data when batch loading base -// payment data and all the related data for a payment. -type paymentsCompleteData struct { - *paymentsBaseData - *paymentsDetailsData -} - -// batchLoadPayments loads the full payment data for a batch of payment IDs. -func batchLoadPayments(ctx context.Context, cfg *sqldb.QueryConfig, - db SQLQueries, paymentIDs []int64) (*paymentsCompleteData, error) { - - baseData, err := batchLoadpaymentsBaseData(ctx, cfg, db, paymentIDs) - if err != nil { - return nil, fmt.Errorf("failed to load payment base data: %w", - err) - } - - batchData, err := batchLoadPaymentDetailsData( - ctx, cfg, db, paymentIDs, true, - ) - if err != nil { - return nil, fmt.Errorf("failed to load payment batch data: %w", - err) - } - - return &paymentsCompleteData{ - paymentsBaseData: baseData, - paymentsDetailsData: batchData, - }, nil -} - -// paymentsBaseData holds the base payment and intent data for a batch of -// payments. -type paymentsBaseData struct { - // paymentsAndIntents maps payment ID to its payment and intent data. - paymentsAndIntents map[int64]sqlc.PaymentAndIntent -} - -// batchLoadpaymentsBaseData loads the base payment and payment intent data for -// a batch of payment IDs. This complements loadPaymentsBatchData which loads -// related data (attempts, hops, custom records) but not the payment table -// and payment intent table data. -func batchLoadpaymentsBaseData(ctx context.Context, - cfg *sqldb.QueryConfig, db SQLQueries, - paymentIDs []int64) (*paymentsBaseData, error) { - - baseData := &paymentsBaseData{ - paymentsAndIntents: make(map[int64]sqlc.PaymentAndIntent), - } - - if len(paymentIDs) == 0 { - return baseData, nil - } - - err := sqldb.ExecuteBatchQuery( - ctx, cfg, paymentIDs, - func(id int64) int64 { return id }, - func(ctx context.Context, ids []int64) ( - []sqlc.FetchPaymentsByIDsRow, error) { - - records, err := db.FetchPaymentsByIDs( - ctx, ids, - ) - - return records, err - }, - func(ctx context.Context, - payment sqlc.FetchPaymentsByIDsRow) error { - - baseData.paymentsAndIntents[payment.ID] = payment - - return nil - }, - ) - if err != nil { - return nil, fmt.Errorf("failed to fetch payment base "+ - "data: %w", err) - } - - return baseData, nil -} - // paymentsRelatedData holds all the batch-loaded data for multiple payments. // This does not include the base payment and intent data which is fetched // separately. It includes the additional data like attempts, hops, hop custom @@ -1047,104 +964,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 @@ -1597,13 +1474,21 @@ func (s *SQLStore) RegisterAttempt(ctx context.Context, // Register the plain HTLC attempt next. sessionKey := attempt.SessionKey() sessionKeyBytes := sessionKey.Serialize() + attemptHash := paymentHash[:] + if attempt.Hash != nil { + attemptHash = attempt.Hash[:] + } else { + log.Errorf("RegisterAttempt: attempt %d has nil hash, "+ + "falling back to payment identifier %x", + attempt.AttemptID, paymentHash) + } _, err = db.InsertHtlcAttempt(ctx, sqlc.InsertHtlcAttemptParams{ PaymentID: dbPayment.Payment.ID, AttemptIndex: int64(attempt.AttemptID), SessionKey: sessionKeyBytes, AttemptTime: attempt.AttemptTime, - PaymentHash: paymentHash[:], + PaymentHash: attemptHash, FirstHopAmountMsat: int64( attempt.Route.FirstHopAmount.Val.Int(), ), diff --git a/sqldb/sqlc/db_custom.go b/sqldb/sqlc/db_custom.go index 1b8d465e73c..b8d47661687 100644 --- a/sqldb/sqlc/db_custom.go +++ b/sqldb/sqlc/db_custom.go @@ -241,3 +241,32 @@ func (r FetchPaymentsByIDsRow) GetPaymentIntent() PaymentIntent { IntentPayload: r.IntentPayload, } } + +// GetPayment returns the Payment associated with this interface. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FetchNonTerminalPaymentsRow) GetPayment() Payment { + return Payment{ + ID: r.ID, + AmountMsat: r.AmountMsat, + CreatedAt: r.CreatedAt, + PaymentIdentifier: r.PaymentIdentifier, + FailReason: r.FailReason, + } +} + +// GetPaymentIntent returns the PaymentIntent associated with this payment. +// If the payment has no intent (IntentType is NULL), this returns a zero-value +// PaymentIntent. +// +// NOTE: This method is part of the PaymentAndIntent interface. +func (r FetchNonTerminalPaymentsRow) GetPaymentIntent() PaymentIntent { + if !r.IntentType.Valid { + return PaymentIntent{} + } + + return PaymentIntent{ + IntentType: r.IntentType.Int16, + IntentPayload: r.IntentPayload, + } +} diff --git a/sqldb/sqlc/migrations/000014_payments_no_fail_reason_index.down.sql b/sqldb/sqlc/migrations/000014_payments_no_fail_reason_index.down.sql new file mode 100644 index 00000000000..030107920fc --- /dev/null +++ b/sqldb/sqlc/migrations/000014_payments_no_fail_reason_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_payments_no_fail_reason; diff --git a/sqldb/sqlc/migrations/000014_payments_no_fail_reason_index.up.sql b/sqldb/sqlc/migrations/000014_payments_no_fail_reason_index.up.sql new file mode 100644 index 00000000000..97e664ba04a --- /dev/null +++ b/sqldb/sqlc/migrations/000014_payments_no_fail_reason_index.up.sql @@ -0,0 +1,4 @@ +-- Partial index for startup payment recovery queries that filter on +-- fail_reason IS NULL and walk payment IDs in ascending order. +CREATE INDEX IF NOT EXISTS idx_payments_no_fail_reason +ON payments(id) WHERE fail_reason IS NULL; diff --git a/sqldb/sqlc/payments.sql.go b/sqldb/sqlc/payments.sql.go index f5b76b8e907..2bf43082673 100644 --- a/sqldb/sqlc/payments.sql.go +++ b/sqldb/sqlc/payments.sql.go @@ -104,69 +104,6 @@ func (q *Queries) FailPayment(ctx context.Context, arg FailPaymentParams) (sql.R return q.db.ExecContext(ctx, failPayment, arg.FailReason, arg.PaymentIdentifier) } -const fetchAllInflightAttempts = `-- name: FetchAllInflightAttempts :many -SELECT - ha.id, - ha.attempt_index, - ha.payment_id, - ha.session_key, - ha.attempt_time, - ha.payment_hash, - ha.first_hop_amount_msat, - ha.route_total_time_lock, - ha.route_total_amount, - ha.route_source_key -FROM payment_htlc_attempts ha -WHERE NOT EXISTS ( - SELECT 1 FROM payment_htlc_attempt_resolutions hr - WHERE hr.attempt_index = ha.attempt_index -) -AND ha.attempt_index > $1 -ORDER BY ha.attempt_index ASC -LIMIT $2 -` - -type FetchAllInflightAttemptsParams struct { - AttemptIndex int64 - Limit int32 -} - -// Fetch all inflight attempts with their payment data using pagination. -// Returns attempt data joined with payment and intent data to avoid separate queries. -func (q *Queries) FetchAllInflightAttempts(ctx context.Context, arg FetchAllInflightAttemptsParams) ([]PaymentHtlcAttempt, error) { - rows, err := q.db.QueryContext(ctx, fetchAllInflightAttempts, arg.AttemptIndex, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []PaymentHtlcAttempt - for rows.Next() { - var i PaymentHtlcAttempt - if err := rows.Scan( - &i.ID, - &i.AttemptIndex, - &i.PaymentID, - &i.SessionKey, - &i.AttemptTime, - &i.PaymentHash, - &i.FirstHopAmountMsat, - &i.RouteTotalTimeLock, - &i.RouteTotalAmount, - &i.RouteSourceKey, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const fetchHopLevelCustomRecords = `-- name: FetchHopLevelCustomRecords :many SELECT l.id, @@ -454,6 +391,95 @@ func (q *Queries) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds [ return items, nil } +const fetchNonTerminalPayments = `-- name: FetchNonTerminalPayments :many +WITH non_terminal_ids AS ( + SELECT p.id + FROM payments p + WHERE p.fail_reason IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payment_htlc_attempt_resolutions hr + JOIN payment_htlc_attempts ha + ON ha.attempt_index = hr.attempt_index + WHERE ha.payment_id = p.id + AND hr.resolution_type = 1 + ) + + UNION + + SELECT DISTINCT ha.payment_id AS id + FROM payment_htlc_attempts ha + WHERE NOT EXISTS ( + SELECT 1 FROM payment_htlc_attempt_resolutions hr + WHERE hr.attempt_index = ha.attempt_index + ) +) +SELECT + p.id, + p.amount_msat, + p.created_at, + p.payment_identifier, + p.fail_reason, + pi.intent_type, + pi.intent_payload +FROM non_terminal_ids n +JOIN payments p + ON p.id = n.id +LEFT JOIN payment_intents pi + ON pi.payment_id = p.id +WHERE p.id > $1 +ORDER BY p.id ASC +LIMIT $2 +` + +type FetchNonTerminalPaymentsParams struct { + ID int64 + Limit int32 +} + +type FetchNonTerminalPaymentsRow struct { + ID int64 + AmountMsat int64 + CreatedAt time.Time + PaymentIdentifier []byte + FailReason sql.NullInt32 + IntentType sql.NullInt16 + IntentPayload []byte +} + +// Fetch all non-terminal payments using pagination. A payment is +// non-terminal if it has an unresolved attempt, or if it has not been +// permanently failed and has no settled attempt yet. +func (q *Queries) FetchNonTerminalPayments(ctx context.Context, arg FetchNonTerminalPaymentsParams) ([]FetchNonTerminalPaymentsRow, error) { + rows, err := q.db.QueryContext(ctx, fetchNonTerminalPayments, arg.ID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FetchNonTerminalPaymentsRow + for rows.Next() { + var i FetchNonTerminalPaymentsRow + if err := rows.Scan( + &i.ID, + &i.AmountMsat, + &i.CreatedAt, + &i.PaymentIdentifier, + &i.FailReason, + &i.IntentType, + &i.IntentPayload, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const fetchPayment = `-- name: FetchPayment :one SELECT p.id, p.amount_msat, p.created_at, p.payment_identifier, p.fail_reason, diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index a810c606066..3c16292b9f8 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -40,9 +40,6 @@ type Querier interface { FailPayment(ctx context.Context, arg FailPaymentParams) (sql.Result, error) FetchAMPSubInvoiceHTLCs(ctx context.Context, arg FetchAMPSubInvoiceHTLCsParams) ([]FetchAMPSubInvoiceHTLCsRow, error) FetchAMPSubInvoices(ctx context.Context, arg FetchAMPSubInvoicesParams) ([]AmpSubInvoice, error) - // Fetch all inflight attempts with their payment data using pagination. - // Returns attempt data joined with payment and intent data to avoid separate queries. - FetchAllInflightAttempts(ctx context.Context, arg FetchAllInflightAttemptsParams) ([]PaymentHtlcAttempt, error) FetchHopLevelCustomRecords(ctx context.Context, hopIds []int64) ([]PaymentHopCustomRecord, error) FetchHopsForAttempts(ctx context.Context, htlcAttemptIndices []int64) ([]FetchHopsForAttemptsRow, error) // Batch query to fetch only HTLC resolution status for multiple payments. @@ -50,6 +47,10 @@ type Querier interface { // group the resolutions by payment_id in the background. FetchHtlcAttemptResolutionsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptResolutionsForPaymentsRow, error) FetchHtlcAttemptsForPayments(ctx context.Context, paymentIds []int64) ([]FetchHtlcAttemptsForPaymentsRow, error) + // Fetch all non-terminal payments using pagination. A payment is + // non-terminal if it has an unresolved attempt, or if it has not been + // permanently failed and has no settled attempt yet. + FetchNonTerminalPayments(ctx context.Context, arg FetchNonTerminalPaymentsParams) ([]FetchNonTerminalPaymentsRow, error) FetchPayment(ctx context.Context, paymentIdentifier []byte) (FetchPaymentRow, error) // Fetch all duplicate payment records from the payment_duplicates table for // a given payment ID. diff --git a/sqldb/sqlc/queries/payments.sql b/sqldb/sqlc/queries/payments.sql index 6c0a544aeb8..68a9126b4dd 100644 --- a/sqldb/sqlc/queries/payments.sql +++ b/sqldb/sqlc/queries/payments.sql @@ -125,27 +125,46 @@ LEFT JOIN payment_intents pi ON pi.payment_id = p.id WHERE p.id IN (sqlc.slice('payment_ids')/*SLICE:payment_ids*/) ORDER BY p.id ASC; --- name: FetchAllInflightAttempts :many --- Fetch all inflight attempts with their payment data using pagination. --- Returns attempt data joined with payment and intent data to avoid separate queries. -SELECT - ha.id, - ha.attempt_index, - ha.payment_id, - ha.session_key, - ha.attempt_time, - ha.payment_hash, - ha.first_hop_amount_msat, - ha.route_total_time_lock, - ha.route_total_amount, - ha.route_source_key -FROM payment_htlc_attempts ha -WHERE NOT EXISTS ( - SELECT 1 FROM payment_htlc_attempt_resolutions hr - WHERE hr.attempt_index = ha.attempt_index +-- name: FetchNonTerminalPayments :many +-- Fetch all non-terminal payments using pagination. A payment is +-- non-terminal if it has an unresolved attempt, or if it has not been +-- permanently failed and has no settled attempt yet. +WITH non_terminal_ids AS ( + SELECT p.id + FROM payments p + WHERE p.fail_reason IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payment_htlc_attempt_resolutions hr + JOIN payment_htlc_attempts ha + ON ha.attempt_index = hr.attempt_index + WHERE ha.payment_id = p.id + AND hr.resolution_type = 1 + ) + + UNION + + SELECT DISTINCT ha.payment_id AS id + FROM payment_htlc_attempts ha + WHERE NOT EXISTS ( + SELECT 1 FROM payment_htlc_attempt_resolutions hr + WHERE hr.attempt_index = ha.attempt_index + ) ) -AND ha.attempt_index > $1 -ORDER BY ha.attempt_index ASC +SELECT + p.id, + p.amount_msat, + p.created_at, + p.payment_identifier, + p.fail_reason, + pi.intent_type, + pi.intent_payload +FROM non_terminal_ids n +JOIN payments p + ON p.id = n.id +LEFT JOIN payment_intents pi + ON pi.payment_id = p.id +WHERE p.id > $1 +ORDER BY p.id ASC LIMIT $2; -- name: FetchHopsForAttempts :many