invoices/sql: replace catch-all GetInvoice with indexed lookups

The GetInvoice query used an OR IS NULL pattern for each filter
parameter:

  WHERE (i.hash = $1 OR $1 IS NULL)
    AND (i.payment_addr = $2 OR $2 IS NULL)

SQLite's query planner decides on an execution plan at prepare time,
before seeing any parameter values. Because either condition can be
trivially true when its parameter is NULL, the planner conservatively
falls back to a full table scan rather than using the unique indexes on
hash and payment_addr. This caused every invoice lookup and update on
the hot path (HTLC settlement) to scan the entire invoices table.

Replace the single catch-all query with three dedicated queries, each
using a direct equality on a uniquely constrained column:

- GetInvoiceByHash:  WHERE hash = $1
- GetInvoiceByAddr:  WHERE payment_addr = $1  (new, covers AMP path)
- GetInvoiceBySetID: existing, unchanged

Update getInvoiceByRef to route to the appropriate query based on which
fields are present in the InvoiceRef. When both hash and payment address
are provided, we look up by hash and then verify the returned invoice's
payment address matches.

GetInvoice is now unused and removed from the SQLInvoiceQueries
interface.
This commit is contained in:
ziggie committed 2026-03-04 13:04:26 +01:00
1 parent 65f6d75511
commit 0ca69e6f57
4 files changed
+96 -55

No files matched your search

+58 -55
View File
@@ -1,6 +1,7 @@
package invoices package invoices
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
@@ -13,7 +14,6 @@ import (
"github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnutils"
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/sqldb" "github.com/lightningnetwork/lnd/sqldb"
@@ -100,12 +100,12 @@ type SQLInvoiceQueries interface { //nolint:interfacebloat
FilterInvoicesReverse(ctx context.Context, FilterInvoicesReverse(ctx context.Context,
arg sqlc.FilterInvoicesReverseParams) ([]sqlc.Invoice, error) arg sqlc.FilterInvoicesReverseParams) ([]sqlc.Invoice, error)
GetInvoice(ctx context.Context,
arg sqlc.GetInvoiceParams) ([]sqlc.Invoice, error)
GetInvoiceByHash(ctx context.Context, hash []byte) (sqlc.Invoice, GetInvoiceByHash(ctx context.Context, hash []byte) (sqlc.Invoice,
error) error)
GetInvoiceByAddr(ctx context.Context,
paymentAddr []byte) (sqlc.Invoice, error)
GetInvoiceBySetID(ctx context.Context, setID []byte) ([]sqlc.Invoice, GetInvoiceBySetID(ctx context.Context, setID []byte) ([]sqlc.Invoice,
error) error)
@@ -398,75 +398,78 @@ func getInvoiceByRef(ctx context.Context,
return sqlc.Invoice{}, ErrInvoiceNotFound return sqlc.Invoice{}, ErrInvoiceNotFound
} }
// If the reference is a hash only, we can look up the invoice directly // If the reference contains a payment hash we can look up the invoice
// by the payment hash which is faster. // directly by hash using the unique index, avoiding a full table scan.
if ref.IsHashOnly() { // The hash alone uniquely identifies any invoice so additional fields
// in the ref (payment address, set ID) are not needed for the lookup.
if ref.PayHash() != nil {
invoice, err := db.GetInvoiceByHash(ctx, ref.PayHash()[:]) invoice, err := db.GetInvoiceByHash(ctx, ref.PayHash()[:])
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return sqlc.Invoice{}, ErrInvoiceNotFound return sqlc.Invoice{}, ErrInvoiceNotFound
} }
if err != nil {
return invoice, err return sqlc.Invoice{}, fmt.Errorf("unable to fetch "+
"invoice by hash: %w", err)
} }
// Otherwise the reference may include more fields, so we'll need to // If the ref also specifies a payment address, verify it
// assemble the query parameters based on the fields that are set. // matches the invoice found by hash. A mismatch means the ref
var params sqlc.GetInvoiceParams // is equivocating — the hash points to one invoice and the
// address points to another.
if ref.PayHash() != nil { payAddr := ref.PayAddr()
params.Hash = ref.PayHash()[:] if payAddr != nil && *payAddr != BlankPayAddr {
if !bytes.Equal(invoice.PaymentAddr, payAddr[:]) {
return sqlc.Invoice{}, ErrInvRefEquivocation
}
} }
// Newer invoices (0.11 and up) are indexed by payment address in return invoice, nil
// addition to payment hash, but pre 0.8 invoices do not have one at
// all. Only allow lookups for payment address if it is not a blank
// payment address, which is a special-cased value for legacy keysend
// invoices.
if ref.PayAddr() != nil && *ref.PayAddr() != BlankPayAddr {
params.PaymentAddr = ref.PayAddr()[:]
} }
// If the reference has a set ID we'll fetch the invoice which has the // If the reference contains a payment address (AMP payments), look up
// corresponding AMP sub invoice. // directly by payment address using the unique index.
if ref.SetID() != nil { //
params.SetID = ref.SetID()[:] // NOTE: Pre-0.8 invoices do not have a payment address, and blank
} // payment addresses are a special case for legacy keysend invoices.
// Those are handled by the hash fast path above.
var ( payAddr := ref.PayAddr()
rows []sqlc.Invoice if payAddr != nil && *payAddr != BlankPayAddr {
err error invoice, err := db.GetInvoiceByAddr(ctx, payAddr[:])
) if errors.Is(err, sql.ErrNoRows) {
// We need to split the query based on how we intend to look up the
// invoice. If only the set ID is given then we want to have an exact
// match on the set ID. If other fields are given, we want to match on
// those fields and the set ID but with a less strict join condition.
if params.Hash == nil && params.PaymentAddr == nil &&
params.SetID != nil {
rows, err = db.GetInvoiceBySetID(ctx, params.SetID)
} else {
rows, err = db.GetInvoice(ctx, params)
}
switch {
case len(rows) == 0:
return sqlc.Invoice{}, ErrInvoiceNotFound return sqlc.Invoice{}, ErrInvoiceNotFound
}
if err != nil {
return sqlc.Invoice{}, fmt.Errorf("unable to fetch "+
"invoice by payment address: %w", err)
}
case len(rows) > 1: return invoice, nil
// In case the reference is ambiguous, meaning it matches more }
// than one invoice, we'll return an error.
return sqlc.Invoice{}, fmt.Errorf("ambiguous invoice ref: "+
"%s: %s", ref.String(), lnutils.SpewLogClosure(rows))
case err != nil: // If only the set ID is given, look up via the AMP sub-invoice index.
return sqlc.Invoice{}, fmt.Errorf("unable to fetch invoice: %w", if ref.SetID() != nil {
err) rows, err := db.GetInvoiceBySetID(ctx, ref.SetID()[:])
if err != nil {
return sqlc.Invoice{}, fmt.Errorf("unable to fetch "+
"invoice: %w", err)
}
if len(rows) == 0 {
return sqlc.Invoice{}, ErrInvoiceNotFound
}
if len(rows) > 1 {
return sqlc.Invoice{}, fmt.Errorf("ambiguous "+
"invoice ref: set_id=%x matches %d invoices",
ref.SetID(), len(rows))
} }
return rows[0], nil return rows[0], nil
} }
return sqlc.Invoice{}, ErrInvoiceNotFound
}
// fetchInvoice fetches the common invoice data and the AMP state for the // fetchInvoice fetches the common invoice data and the AMP state for the
// invoice with the given reference. // invoice with the given reference.
func fetchInvoice(ctx context.Context, db SQLInvoiceQueries, ref InvoiceRef) ( func fetchInvoice(ctx context.Context, db SQLInvoiceQueries, ref InvoiceRef) (
+32
View File
@@ -476,6 +476,38 @@ func (q *Queries) GetInvoice(ctx context.Context, arg GetInvoiceParams) ([]Invoi
return items, nil return items, nil
} }
const getInvoiceByAddr = `-- name: GetInvoiceByAddr :one
SELECT i.id, i.hash, i.preimage, i.settle_index, i.settled_at, i.memo, i.amount_msat, i.cltv_delta, i.expiry, i.payment_addr, i.payment_request, i.payment_request_hash, i.state, i.amount_paid_msat, i.is_amp, i.is_hodl, i.is_keysend, i.created_at
FROM invoices i
WHERE i.payment_addr = $1
`
func (q *Queries) GetInvoiceByAddr(ctx context.Context, paymentAddr []byte) (Invoice, error) {
row := q.db.QueryRowContext(ctx, getInvoiceByAddr, paymentAddr)
var i Invoice
err := row.Scan(
&i.ID,
&i.Hash,
&i.Preimage,
&i.SettleIndex,
&i.SettledAt,
&i.Memo,
&i.AmountMsat,
&i.CltvDelta,
&i.Expiry,
&i.PaymentAddr,
&i.PaymentRequest,
&i.PaymentRequestHash,
&i.State,
&i.AmountPaidMsat,
&i.IsAmp,
&i.IsHodl,
&i.IsKeysend,
&i.CreatedAt,
)
return i, err
}
const getInvoiceByHash = `-- name: GetInvoiceByHash :one const getInvoiceByHash = `-- name: GetInvoiceByHash :one
SELECT i.id, i.hash, i.preimage, i.settle_index, i.settled_at, i.memo, i.amount_msat, i.cltv_delta, i.expiry, i.payment_addr, i.payment_request, i.payment_request_hash, i.state, i.amount_paid_msat, i.is_amp, i.is_hodl, i.is_keysend, i.created_at SELECT i.id, i.hash, i.preimage, i.settle_index, i.settled_at, i.memo, i.amount_msat, i.cltv_delta, i.expiry, i.payment_addr, i.payment_request, i.payment_request_hash, i.state, i.amount_paid_msat, i.is_amp, i.is_hodl, i.is_keysend, i.created_at
FROM invoices i FROM invoices i
+1
View File
@@ -111,6 +111,7 @@ type Querier interface {
// from different invoices. It is the caller's responsibility to ensure that // from different invoices. It is the caller's responsibility to ensure that
// we bubble up an error in those cases. // we bubble up an error in those cases.
GetInvoice(ctx context.Context, arg GetInvoiceParams) ([]Invoice, error) GetInvoice(ctx context.Context, arg GetInvoiceParams) ([]Invoice, error)
GetInvoiceByAddr(ctx context.Context, paymentAddr []byte) (Invoice, error)
GetInvoiceByHash(ctx context.Context, hash []byte) (Invoice, error) GetInvoiceByHash(ctx context.Context, hash []byte) (Invoice, error)
GetInvoiceBySetID(ctx context.Context, setID []byte) ([]Invoice, error) GetInvoiceBySetID(ctx context.Context, setID []byte) ([]Invoice, error)
GetInvoiceFeatures(ctx context.Context, invoiceID int64) ([]InvoiceFeature, error) GetInvoiceFeatures(ctx context.Context, invoiceID int64) ([]InvoiceFeature, error)
+5
View File
@@ -59,6 +59,11 @@ SELECT i.*
FROM invoices i FROM invoices i
WHERE i.hash = $1; WHERE i.hash = $1;
-- name: GetInvoiceByAddr :one
SELECT i.*
FROM invoices i
WHERE i.payment_addr = $1;
-- name: GetInvoiceBySetID :many -- name: GetInvoiceBySetID :many
SELECT i.* SELECT i.*
FROM invoices i FROM invoices i