sqldb/sqlc: split public-only node horizon query and upgrade channel indexes
What changed, and why it matters
This commit is a performance and correctness improvement for LND's channel graph database queries. It splits one database query into two specialized versions—one that returns all nodes and one that returns only publicly announced nodes—and updates database indexes to make the public-node check faster. There is no direct security vulnerability being patched; it is a query optimization and schema refinement.
Treat as a routine performance/correctness improvement. Review the migration for large-table index rebuild impact and verify the new test passes. No urgent security action is required.
Security signals we found
No security-relevant behavior change: the public/private node classification logic is unchanged
Query optimization only: split OR predicate into two EXISTS probes to enable index usage
Schema migration adds composite indexes including version column
New regression test added for public-only node horizon filtering
Evidence from the diff
The change refactors GetNodesByLastUpdateRange into an all-nodes query and a new GetPublicNodesByLastUpdateRange. The public-only variant replaces a single EXISTS ... WHERE (c.node_id_1 = graph_nodes.id OR c.node_id_2 = graph_nodes.id) clause with two separate EXISTS probes, one per node_id column, so the query planner can use the graph_channels_node_id_1_idx / graph_channels_node_id_2_idx indexes directly. The migration upgrades those indexes from single-column (node_id_1) / (node_id_2) to composite (node_id_1, version) / (node_id_2, version) to support version-aware public-node checks while keeping node-centric lookups efficient. A new test verifies that the public-only iterator returns only nodes with at least one public channel.
Changed components
graph/db/sql_store.gosqldb/sqlc/graph.sql.gosqldb/sqlc/queries/graph.sqlsqldb/sqlc/querier.gosqldb/sqlc/migrations/000009_graph_v2.up.sqlsqldb/sqlc/migrations/000009_graph_v2.down.sqlgraph/db/graph_test.goInspect captured patch +281 / −57
diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go
index ae99121..1dac7fa 100644
--- a/graph/db/graph_test.go
+++ b/graph/db/graph_test.go
@@ -2684,6 +2684,91 @@ func TestNodeUpdatesInHorizon(t *testing.T) {
}
}
+// TestNodeUpdatesInHorizonPublicOnly tests that NodeUpdatesInHorizon with
+// WithIterPublicNodesOnly returns only nodes that have at least one public
+// channel (one with a channel announcement proof).
+func TestNodeUpdatesInHorizonPublicOnly(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ chanGraph := MakeTestGraph(t)
+ graph := NewVersionedGraph(chanGraph, lnwire.GossipVersion1)
+
+ startTime := time.Unix(1000, 0)
+
+ // Create 4 nodes: we'll make node pairs where one pair has a public
+ // channel (with proof) and the other has a private channel (no proof).
+ publicNode1 := createTestVertex(t, lnwire.GossipVersion1)
+ publicNode1.LastUpdate = startTime.Add(10 * time.Second)
+
+ // Set publicNode1 as the source node (required before adding
+ // channel edges).
+ require.NoError(t, chanGraph.SetSourceNode(ctx, publicNode1))
+
+ publicNode2 := createTestVertex(t, lnwire.GossipVersion1)
+ publicNode2.LastUpdate = startTime.Add(20 * time.Second)
+ require.NoError(t, chanGraph.AddNode(ctx, publicNode2))
+
+ // privateNode has a channel to the source node (publicNode1) but
+ // without a proof, so it remains private in both KV and SQL backends.
+ privateNode := createTestVertex(t, lnwire.GossipVersion1)
+ privateNode.LastUpdate = startTime.Add(30 * time.Second)
+ require.NoError(t, chanGraph.AddNode(ctx, privateNode))
+
+ // Create a standalone node with no channels at all.
+ lonelyNode := createTestVertex(t, lnwire.GossipVersion1)
+ lonelyNode.LastUpdate = startTime.Add(40 * time.Second)
+ require.NoError(t, chanGraph.AddNode(ctx, lonelyNode))
+
+ // Add a public channel between publicNode1 and publicNode2
+ // (with proof, making both nodes public).
+ publicEdge, _ := createEdge(
+ lnwire.GossipVersion1, 100, 0, 0, 0,
+ publicNode1, publicNode2,
+ )
+ require.NoError(t, chanGraph.AddChannelEdge(ctx, publicEdge))
+
+ // Add a private channel between publicNode1 (source) and
+ // privateNode (no proof, so privateNode remains private).
+ privateEdge, _ := createEdge(
+ lnwire.GossipVersion1, 200, 0, 0, 1,
+ publicNode1, privateNode, true, // skipProof
+ )
+ require.NoError(t, chanGraph.AddChannelEdge(ctx, privateEdge))
+
+ // Query without the public-only filter — should return all 4 nodes.
+ endTime := startTime.Add(60 * time.Second)
+ r := NodeUpdateRange{
+ StartTime: fn.Some(startTime),
+ EndTime: fn.Some(endTime),
+ }
+ allIter := graph.NodeUpdatesInHorizon(ctx, r)
+ allNodes, err := fn.CollectErr(allIter)
+ require.NoError(t, err)
+ require.Len(t, allNodes, 4)
+
+ // Query with the public-only filter — should return only the 2
+ // public nodes.
+ publicIter := graph.NodeUpdatesInHorizon(
+ ctx, r, WithIterPublicNodesOnly(),
+ )
+ publicNodes, err := fn.CollectErr(publicIter)
+ require.NoError(t, err)
+ require.Len(t, publicNodes, 2)
+
+ // Verify the returned nodes are exactly the public ones.
+ pub1Key := publicNode1.PubKeyBytes
+ pub2Key := publicNode2.PubKeyBytes
+ for _, node := range publicNodes {
+ require.True(
+ t, node.PubKeyBytes == pub1Key ||
+ node.PubKeyBytes == pub2Key,
+ "unexpected node in public-only results: %x",
+ node.PubKeyBytes,
+ )
+ }
+}
+
// testNodeUpdatesWithBatchSize is a helper function that tests node updates
// with a specific batch size to ensure the iterator works correctly across
// batch boundaries.
diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go
index e448523..34c36d6 100644
--- a/graph/db/sql_store.go
+++ b/graph/db/sql_store.go
@@ -53,6 +53,7 @@ type SQLQueries interface {
GetNodeIDByPubKey(ctx context.Context, arg sqlc.GetNodeIDByPubKeyParams) (int64, error)
GetNodesByLastUpdateRange(ctx context.Context, arg sqlc.GetNodesByLastUpdateRangeParams) ([]sqlc.GraphNode, error)
GetNodesByBlockHeightRange(ctx context.Context, arg sqlc.GetNodesByBlockHeightRangeParams) ([]sqlc.GraphNode, error)
+ GetPublicNodesByLastUpdateRange(ctx context.Context, arg sqlc.GetPublicNodesByLastUpdateRangeParams) ([]sqlc.GraphNode, error)
ListNodesPaginated(ctx context.Context, arg sqlc.ListNodesPaginatedParams) ([]sqlc.GraphNode, error)
ListNodeIDsAndPubKeys(ctx context.Context, arg sqlc.ListNodeIDsAndPubKeysParams) ([]sqlc.ListNodeIDsAndPubKeysRow, error)
IsPublicV1Node(ctx context.Context, pubKey []byte) (bool, error)
@@ -666,26 +667,10 @@ func (s *SQLStore) nodeUpdatesInHorizonV1(ctx context.Context,
//nolint:ll
err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error {
- //nolint:ll
- params := sqlc.GetNodesByLastUpdateRangeParams{
- StartTime: sqldb.SQLInt64(
- startTime.Unix(),
- ),
- EndTime: sqldb.SQLInt64(
- endTime.Unix(),
- ),
- LastUpdate: lastUpdateTime,
- LastPubKey: lastPubKey,
- OnlyPublic: sql.NullBool{
- Bool: cfg.iterPublicNodes,
- Valid: true,
- },
- MaxResults: sqldb.SQLInt32(
- cfg.nodeUpdateIterBatchSize,
- ),
- }
- rows, err := db.GetNodesByLastUpdateRange(
- ctx, params,
+ rows, err := nodesByLastUpdateRange(
+ ctx, db, cfg, startTime,
+ endTime, lastUpdateTime,
+ lastPubKey,
)
if err != nil {
return err
@@ -849,6 +834,48 @@ func (s *SQLStore) nodeUpdatesInHorizonV2(ctx context.Context,
}
}
+// nodesByLastUpdateRange dispatches to either the all-nodes or public-only
+// variant of the v1 node horizon query based on the iterator config.
+func nodesByLastUpdateRange(ctx context.Context, db SQLQueries,
+ cfg *iterConfig, startTime, endTime time.Time,
+ lastUpdateTime sql.NullInt64,
+ lastPubKey []byte) ([]sqlc.GraphNode, error) {
+
+ if cfg.iterPublicNodes {
+ return db.GetPublicNodesByLastUpdateRange(
+ ctx, sqlc.GetPublicNodesByLastUpdateRangeParams{
+ StartTime: sqldb.SQLInt64(
+ startTime.Unix(),
+ ),
+ EndTime: sqldb.SQLInt64(
+ endTime.Unix(),
+ ),
+ LastUpdate: lastUpdateTime,
+ LastPubKey: lastPubKey,
+ MaxResults: sqldb.SQLInt32(
+ cfg.nodeUpdateIterBatchSize,
+ ),
+ },
+ )
+ }
+
+ return db.GetNodesByLastUpdateRange(
+ ctx, sqlc.GetNodesByLastUpdateRangeParams{
+ StartTime: sqldb.SQLInt64(
+ startTime.Unix(),
+ ),
+ EndTime: sqldb.SQLInt64(
+ endTime.Unix(),
+ ),
+ LastUpdate: lastUpdateTime,
+ LastPubKey: lastPubKey,
+ MaxResults: sqldb.SQLInt32(
+ cfg.nodeUpdateIterBatchSize,
+ ),
+ },
+ )
+}
+
// AddChannelEdge adds a new (undirected, blank) edge to the graph database. An
// undirected edge from the two target nodes are created. The information stored
// denotes the static attributes of the channel, such as the channelID, the keys
diff --git a/sqldb/sqlc/graph.sql.go b/sqldb/sqlc/graph.sql.go
index e841de4..703afd8 100644
--- a/sqldb/sqlc/graph.sql.go
+++ b/sqldb/sqlc/graph.sql.go
@@ -2440,30 +2440,13 @@ WHERE version = 1
-- This ensures stable ordering and allows us to resume from where we left off.
-- We use COALESCE with -1 as sentinel since timestamps are always positive.
AND (
- -- Include rows with last_update greater than cursor (or all rows if cursor is -1)
last_update > COALESCE($3, -1)
- OR
- -- For rows with same last_update, use pub_key as tiebreaker
- (last_update = COALESCE($3, -1)
+ OR
+ (last_update = COALESCE($3, -1)
AND pub_key > $4)
)
- -- Optional filter for public nodes only
- AND (
- -- If only_public is false or not provided, include all nodes
- COALESCE($5, FALSE) IS FALSE
- OR
- -- For V1 protocol, a node is public if it has at least one public channel.
- -- A public channel has bitcoin_1_signature set (channel announcement received).
- EXISTS (
- SELECT 1
- FROM graph_channels c
- WHERE c.version = 1
- AND COALESCE(length(c.bitcoin_1_signature), 0) > 0
- AND (c.node_id_1 = graph_nodes.id OR c.node_id_2 = graph_nodes.id)
- )
- )
ORDER BY last_update ASC, pub_key ASC
-LIMIT COALESCE($6, 999999999)
+LIMIT COALESCE($5, 999999999)
`
type GetNodesByLastUpdateRangeParams struct {
@@ -2471,7 +2454,6 @@ type GetNodesByLastUpdateRangeParams struct {
EndTime sql.NullInt64
LastUpdate sql.NullInt64
LastPubKey []byte
- OnlyPublic interface{}
MaxResults interface{}
}
@@ -2481,7 +2463,6 @@ func (q *Queries) GetNodesByLastUpdateRange(ctx context.Context, arg GetNodesByL
arg.EndTime,
arg.LastUpdate,
arg.LastPubKey,
- arg.OnlyPublic,
arg.MaxResults,
)
if err != nil {
@@ -2581,6 +2562,92 @@ func (q *Queries) GetPruneTip(ctx context.Context) (GraphPruneLog, error) {
return i, err
}
+const getPublicNodesByLastUpdateRange = `-- name: GetPublicNodesByLastUpdateRange :many
+SELECT id, version, pub_key, alias, last_update, color, signature, block_height
+FROM graph_nodes
+WHERE version = 1
+ AND last_update >= $1
+ AND last_update <= $2
+ -- Pagination: We use (last_update, pub_key) as a compound cursor.
+ -- This ensures stable ordering and allows us to resume from where we left off.
+ -- We use COALESCE with -1 as sentinel since timestamps are always positive.
+ AND (
+ last_update > COALESCE($3, -1)
+ OR
+ (last_update = COALESCE($3, -1)
+ AND pub_key > $4)
+ )
+ AND (
+ EXISTS (
+ SELECT 1
+ FROM graph_channels c
+ WHERE c.version = 1
+ AND COALESCE(length(c.bitcoin_1_signature), 0) > 0
+ AND c.node_id_1 = graph_nodes.id
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM graph_channels c
+ WHERE c.version = 1
+ AND COALESCE(length(c.bitcoin_1_signature), 0) > 0
+ AND c.node_id_2 = graph_nodes.id
+ )
+ )
+ORDER BY last_update ASC, pub_key ASC
+LIMIT COALESCE($5, 999999999)
+`
+
+type GetPublicNodesByLastUpdateRangeParams struct {
+ StartTime sql.NullInt64
+ EndTime sql.NullInt64
+ LastUpdate sql.NullInt64
+ LastPubKey []byte
+ MaxResults interface{}
+}
+
+// Returns only public V1 nodes within the given last_update range. A V1 node
+// is public if it has at least one channel with a bitcoin_1_signature set. The
+// public check uses two separate EXISTS probes (one per node_id column)
+// instead of a single OR on node_id_1/node_id_2 so the planner can use the
+// channel node-id indexes directly.
+func (q *Queries) GetPublicNodesByLastUpdateRange(ctx context.Context, arg GetPublicNodesByLastUpdateRangeParams) ([]GraphNode, error) {
+ rows, err := q.db.QueryContext(ctx, getPublicNodesByLastUpdateRange,
+ arg.StartTime,
+ arg.EndTime,
+ arg.LastUpdate,
+ arg.LastPubKey,
+ arg.MaxResults,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GraphNode
+ for rows.Next() {
+ var i GraphNode
+ if err := rows.Scan(
+ &i.ID,
+ &i.Version,
+ &i.PubKey,
+ &i.Alias,
+ &i.LastUpdate,
+ &i.Color,
+ &i.Signature,
+ &i.BlockHeight,
+ ); 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 getPublicV1ChannelsBySCID = `-- name: GetPublicV1ChannelsBySCID :many
SELECT id, version, scid, node_id_1, node_id_2, outpoint, capacity, bitcoin_key_1, bitcoin_key_2, node_1_signature, node_2_signature, bitcoin_1_signature, bitcoin_2_signature, signature, funding_pk_script, merkle_root_hash
FROM graph_channels
diff --git a/sqldb/sqlc/migrations/000009_graph_v2.down.sql b/sqldb/sqlc/migrations/000009_graph_v2.down.sql
index 13d4422..f3e04d6 100644
--- a/sqldb/sqlc/migrations/000009_graph_v2.down.sql
+++ b/sqldb/sqlc/migrations/000009_graph_v2.down.sql
@@ -6,6 +6,12 @@ DROP INDEX IF EXISTS graph_channel_policy_block_height_idx;
DROP INDEX IF EXISTS graph_node_last_update_idx;
CREATE INDEX IF NOT EXISTS graph_node_last_update_idx ON graph_nodes(last_update);
+-- Restore the original single-column channel node-id indexes.
+DROP INDEX IF EXISTS graph_channels_node_id_1_idx;
+DROP INDEX IF EXISTS graph_channels_node_id_2_idx;
+CREATE INDEX IF NOT EXISTS graph_channels_node_id_1_idx ON graph_channels(node_id_1);
+CREATE INDEX IF NOT EXISTS graph_channels_node_id_2_idx ON graph_channels(node_id_2);
+
-- Remove the block_height column from graph_nodes
ALTER TABLE graph_nodes DROP COLUMN block_height;
diff --git a/sqldb/sqlc/migrations/000009_graph_v2.up.sql b/sqldb/sqlc/migrations/000009_graph_v2.up.sql
index 2de4dd7..fa029d8 100644
--- a/sqldb/sqlc/migrations/000009_graph_v2.up.sql
+++ b/sqldb/sqlc/migrations/000009_graph_v2.up.sql
@@ -44,3 +44,14 @@ CREATE INDEX IF NOT EXISTS graph_channel_policy_block_height_idx
DROP INDEX IF EXISTS graph_node_last_update_idx;
CREATE INDEX IF NOT EXISTS graph_node_last_update_idx
ON graph_nodes(version, last_update, pub_key);
+
+-- Replace the single-column channel node-id indexes with composite indexes
+-- that include version. This helps the version-aware public node checks
+-- (UNION ALL probes) for both v1 and v2, while still serving node-centric
+-- lookups like channel iteration and existence checks.
+DROP INDEX IF EXISTS graph_channels_node_id_1_idx;
+DROP INDEX IF EXISTS graph_channels_node_id_2_idx;
+CREATE INDEX IF NOT EXISTS graph_channels_node_id_1_idx
+ ON graph_channels(node_id_1, version);
+CREATE INDEX IF NOT EXISTS graph_channels_node_id_2_idx
+ ON graph_channels(node_id_2, version);
diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go
index afe77ef..a739f5c 100644
--- a/sqldb/sqlc/querier.go
+++ b/sqldb/sqlc/querier.go
@@ -135,6 +135,12 @@ type Querier interface {
GetPruneEntriesForHeights(ctx context.Context, heights []int64) ([]GraphPruneLog, error)
GetPruneHashByHeight(ctx context.Context, blockHeight int64) ([]byte, error)
GetPruneTip(ctx context.Context) (GraphPruneLog, error)
+ // Returns only public V1 nodes within the given last_update range. A V1 node
+ // is public if it has at least one channel with a bitcoin_1_signature set. The
+ // public check uses two separate EXISTS probes (one per node_id column)
+ // instead of a single OR on node_id_1/node_id_2 so the planner can use the
+ // channel node-id indexes directly.
+ GetPublicNodesByLastUpdateRange(ctx context.Context, arg GetPublicNodesByLastUpdateRangeParams) ([]GraphNode, error)
GetPublicV1ChannelsBySCID(ctx context.Context, arg GetPublicV1ChannelsBySCIDParams) ([]GraphChannel, error)
GetPublicV2ChannelsBySCID(ctx context.Context, arg GetPublicV2ChannelsBySCIDParams) ([]GraphChannel, error)
GetSCIDByOutpoint(ctx context.Context, arg GetSCIDByOutpointParams) ([]byte, error)
diff --git a/sqldb/sqlc/queries/graph.sql b/sqldb/sqlc/queries/graph.sql
index ca6c42b..a7683d1 100644
--- a/sqldb/sqlc/queries/graph.sql
+++ b/sqldb/sqlc/queries/graph.sql
@@ -234,26 +234,48 @@ WHERE version = 1
-- This ensures stable ordering and allows us to resume from where we left off.
-- We use COALESCE with -1 as sentinel since timestamps are always positive.
AND (
- -- Include rows with last_update greater than cursor (or all rows if cursor is -1)
last_update > COALESCE(sqlc.narg('last_update'), -1)
- OR
- -- For rows with same last_update, use pub_key as tiebreaker
- (last_update = COALESCE(sqlc.narg('last_update'), -1)
+ OR
+ (last_update = COALESCE(sqlc.narg('last_update'), -1)
+ AND pub_key > sqlc.narg('last_pub_key'))
+ )
+ORDER BY last_update ASC, pub_key ASC
+LIMIT COALESCE(sqlc.narg('max_results'), 999999999);
+
+-- name: GetPublicNodesByLastUpdateRange :many
+-- Returns only public V1 nodes within the given last_update range. A V1 node
+-- is public if it has at least one channel with a bitcoin_1_signature set. The
+-- public check uses two separate EXISTS probes (one per node_id column)
+-- instead of a single OR on node_id_1/node_id_2 so the planner can use the
+-- channel node-id indexes directly.
+SELECT *
+FROM graph_nodes
+WHERE version = 1
+ AND last_update >= @start_time
+ AND last_update <= @end_time
+ -- Pagination: We use (last_update, pub_key) as a compound cursor.
+ -- This ensures stable ordering and allows us to resume from where we left off.
+ -- We use COALESCE with -1 as sentinel since timestamps are always positive.
+ AND (
+ last_update > COALESCE(sqlc.narg('last_update'), -1)
+ OR
+ (last_update = COALESCE(sqlc.narg('last_update'), -1)
AND pub_key > sqlc.narg('last_pub_key'))
)
- -- Optional filter for public nodes only
AND (
- -- If only_public is false or not provided, include all nodes
- COALESCE(sqlc.narg('only_public'), FALSE) IS FALSE
- OR
- -- For V1 protocol, a node is public if it has at least one public channel.
- -- A public channel has bitcoin_1_signature set (channel announcement received).
EXISTS (
- SELECT 1
- FROM graph_channels c
- WHERE c.version = 1
- AND COALESCE(length(c.bitcoin_1_signature), 0) > 0
- AND (c.node_id_1 = graph_nodes.id OR c.node_id_2 = graph_nodes.id)
+ SELECT 1
+ FROM graph_channels c
+ WHERE c.version = 1
+ AND COALESCE(length(c.bitcoin_1_signature), 0) > 0
+ AND c.node_id_1 = graph_nodes.id
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM graph_channels c
+ WHERE c.version = 1
+ AND COALESCE(length(c.bitcoin_1_signature), 0) > 0
+ AND c.node_id_2 = graph_nodes.id
)
)
ORDER BY last_update ASC, pub_key ASC
Why this scored 22/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.