Skip to content

graph/db: cross-version graph Store queries#10714

Open
ellemouton wants to merge 6 commits intolightningnetwork:masterfrom
ellemouton:g175-graph-db-cross-version-v2
Open

graph/db: cross-version graph Store queries#10714
ellemouton wants to merge 6 commits intolightningnetwork:masterfrom
ellemouton:g175-graph-db-cross-version-v2

Conversation

@ellemouton
Copy link
Copy Markdown
Collaborator

Summary

This PR makes the graph Store interface cross-version so that ForEachNode, ForEachChannel, and ForEachNodeDirectedChannel work across gossip v1 and v2, returning one preferred entry per pub_key/SCID. It also adds PreferHighest fetch helpers and GetVersions queries so callers can retrieve channels without knowing which gossip version announced them.

Part of the gossip v2 epic: #10293
Spun off from: #10656

Design

Two new materialised mapping tables are introduced:

  • graph_preferred_nodes — one row per unique pub_key, pointing at the best graph_nodes row. Priority: v2 announced > v1 announced > v2 shell > v1 shell.
  • graph_preferred_channels — one row per unique SCID, pointing at the best graph_channels row. Priority: v2 with policies > v1 with policies > v2 bare > v1 bare.

These tables are maintained on every write path (AddNode, AddChannelEdge, UpdateEdgePolicy, DeleteNode, DeleteChannelEdges, PruneGraphNodes) via UpsertPreferredNode/UpsertPreferredChannel SQL queries, and seeded by a migration for existing data.

Changes by commit

  1. sqldb: add preferred-node and preferred-channel mapping tables — migration 000014 creating the tables and populating them from existing data.
  2. graph/db: add PreferHighest fetch methods and GetVersions queries — new Store interface methods: FetchChannelEdgesByIDPreferHighest, FetchChannelEdgesByOutpointPreferHighest, GetVersionsBySCID, GetVersionsByOutpoint. KV implementations delegate to v1.
  3. graph/db: wire preferred-table maintenance into write paths — calls UpsertPreferredNode/UpsertPreferredChannel on all SQL write paths.
  4. graph/db: make ForEachNode and ForEachChannel cross-version — removes the GossipVersion parameter from ForEachNode and ForEachChannel on the Store interface, replacing the underlying queries with preferred-table-backed paginated queries.
  5. graph/db: implement cross-version node traversal — adds ForEachNodeDirectedChannelPreferHighest for cache-disabled SQL path, updates FetchNodeFeatures to prefer v2 features with v1 fallback.
  6. docs: add release note

Performance

Benchmarked on Apple M1 Pro against a mainnet v1-only graph (16,216 nodes, 51,239 channels). No regressions observed — the preferred-table JOIN adds negligible overhead:

Method Backend Result
ForEachNode native-sqlite ~216 ms/op
ForEachNode native-postgres ~102 ms/op
ForEachChannel native-sqlite ~1,482 ms/op
ForEachChannel native-postgres ~538 ms/op

Test plan

  • New unit tests: TestPreferHighestAndGetVersions, TestPreferHighestForEachNode, TestPreferHighestForEachChannel, TestPreferHighestNodeTraversal, TestPreferHighestNodeDirectedChannelTraversal, TestDeleteNodePreferredRecomputation
  • All existing graph DB tests pass (v1 behaviour preserved)
  • Benchmark confirms no performance regression for v1-only graphs
  • SQL-only tests skipped on KV backend via isSQLDB guard

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request refactors the graph database layer to support cross-version gossip data. By introducing materialized mapping tables, the system can now automatically select the preferred version of nodes and channels based on defined priority rules. This change simplifies the API for callers and ensures that graph traversal methods consistently return the most relevant data, regardless of whether it was announced via gossip v1 or v2.

Highlights

  • Cross-version Store Interface: Updated the graph Store interface to be version-agnostic, allowing ForEachNode, ForEachChannel, and ForEachNodeDirectedChannel to operate across gossip v1 and v2 seamlessly.
  • Preferred Mapping Tables: Introduced graph_preferred_nodes and graph_preferred_channels materialized tables to maintain the best version of nodes and channels, ensuring efficient cross-version queries.
  • New Fetch Helpers: Added PreferHighest fetch methods and GetVersions queries to enable callers to retrieve the best available data without needing to specify the gossip version.
  • Write Path Maintenance: Integrated automatic updates to the preferred mapping tables within all SQL write paths to ensure data consistency.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the graph Store interface to support cross-version operations, enabling node and channel traversals to work across gossip v1 and v2. It introduces preferred mapping tables in the SQL backend and PreferHighest fetch helpers to prioritize v2 data while falling back to v1. Feedback was provided to implement a feature fallback in the no-cache path for directed channel traversals, ensuring consistency with the FetchNodeFeatures logic by providing the best available feature set when a specific gossip version lacks data.

Comment thread graph/db/sql_store.go
}
}

toNodeCallback := func() route.Vertex {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the no-cache path, we should implement a fallback for node features similar to the logic in ChannelGraph.FetchNodeFeatures. This ensures that if a node has v1 features but no v2 features, we still provide the v1 features for pathfinding even if the channel announcement is v2. This maintains consistency between the cache-enabled and cache-disabled code paths.

	// Try v2 first, fall back to v1 if the v2 features are empty. This
	// ensures consistency with FetchNodeFeatures and provides the best
	// available feature set for the node.
	preferredFeatures := featuresByVersion[gossipV2]
	if preferredFeatures.IsEmpty() {
		preferredFeatures = featuresByVersion[gossipV1]
	}

	toNodeCallback := func() route.Vertex {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current behaviour is actually more correct here. Tying ToNodeFeatures to the channel's gossip version ensures the features match the protocol version of the channel advertisement. A v2 preferred channel should use v2 features, and a v1 preferred channel should use v1 features.

Using a single "best" feature set across all channels would mean a v1 channel could get v2 features assigned, which is a version mismatch.

The underlying ambiguity (empty features vs absent node for a given version) is a known limitation — the proper fix is to distinguish those two cases at the FetchNodeFeatures layer rather than papering over it here.

Comment thread graph/db/sql_store.go
Comment on lines +4246 to +4249
cachedInPolicy.ToNodeFeatures =
featuresByVersion[lnwire.GossipVersion(
row.GraphChannel.Version,
)]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the pre-calculated preferredFeatures here to ensure the best available feature set is used for the node, regardless of the channel's gossip version.

Suggested change
cachedInPolicy.ToNodeFeatures =
featuresByVersion[lnwire.GossipVersion(
row.GraphChannel.Version,
)]
cachedInPolicy.ToNodeFeatures = preferredFeatures

@ellemouton ellemouton force-pushed the g175-graph-db-cross-version-v2 branch from 32f201a to 652f923 Compare April 7, 2026 07:25
@ellemouton ellemouton force-pushed the g175-graph-db-cross-version-v2 branch from 652f923 to 24c9c8d Compare April 7, 2026 07:36
@saubyk saubyk added this to the v0.22.0 milestone Apr 7, 2026
@saubyk saubyk added this to lnd v0.22 Apr 7, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in lnd v0.22 Apr 7, 2026
@saubyk saubyk moved this from Backlog to In progress in lnd v0.22 Apr 7, 2026
Comment thread sqldb/sqlc/migrations/000015_graph_preferred_lookups.up.sql
Comment thread sqldb/sqlc/queries/graph.sql Outdated
Comment on lines +951 to +954
WHERE (n1.pub_key = @pub_key OR n2.pub_key = @pub_key)
AND pc.scid > @scid
ORDER BY pc.scid
LIMIT @page_limit;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it would be good to stay consistent with either the $ or @ syntax

Comment thread sqldb/sqlc/queries/graph.sql Outdated
ORDER BY c.id
LIMIT $3;

-- name: ListPreferredChannelsWithPoliciesPaginated :many
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming-nit: we could make the naming more consistent
ListPreferredChannelsPaginated and ListPreferredNodeChannelsPaginated
or with the WithPolicies addition.

Comment thread graph/db/sql_store.go Outdated
Comment on lines 3832 to 3856
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps it makes sense to shorten:

	prunedNodes := make([]route.Vertex, len(nodeKeys))
	for i, nodeKey := range nodeKeys {
		err = db.UpsertPreferredNode(ctx, nodeKey)
		if err != nil {
			return nil, fmt.Errorf("recalc preferred node: %w", err)
		}

		pub, err := route.NewVertexFromBytes(nodeKey)
		if err != nil {
			return nil, fmt.Errorf("unable to parse pubkey from bytes: %w", err)
		}

		prunedNodes[i] = pub
	}

	return prunedNodes, nil

since the upsert is idempotent? but maybe an additional dedup can make sense

Comment thread graph/db/kv_store.go
}
}

// IsPublicNode is a helper method that determines whether the node with the
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed doc

Comment thread graph/db/sql_store.go Outdated
Comment on lines 6949 to 6954
pageIDs []channelBatchIDs) (*batchChannelData, error) {

for i, ids := range allIDs {
channelIDs := make([]int64, len(pageIDs))
policyIDs := make([]int64, 0, len(pageIDs)*2)
for i, ids := range pageIDs {
channelIDs[i] = ids.channelID
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: change maybe not needed

Comment thread graph/db/graph_test.go
ctx, models.NewShellNode(lnwire.GossipVersion2, shellPub),
))

nodesByPub := make(map[route.Vertex]*models.Node)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tiny nit: the map de-duplicates the results and would let a duplicate node pass, same with channels below

Comment thread sqldb/sqlc/queries/graph.sql Outdated
Comment on lines +1580 to +1581
-- name: DeletePreferredNode :exec
DELETE FROM graph_preferred_nodes WHERE pub_key = $1;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like the delete queries are unused as we rely on the cascade functionality

Comment thread graph/db/sql_store.go Outdated
GetChannelAndNodesBySCID(ctx context.Context, arg sqlc.GetChannelAndNodesBySCIDParams) (sqlc.GetChannelAndNodesBySCIDRow, error)
HighestSCID(ctx context.Context, version int16) ([]byte, error)
ListChannelsByNodeID(ctx context.Context, arg sqlc.ListChannelsByNodeIDParams) ([]sqlc.ListChannelsByNodeIDRow, error)
ListPreferredDirectedChannelsPaginated(ctx context.Context, arg sqlc.ListPreferredDirectedChannelsPaginatedParams) ([]sqlc.ListPreferredDirectedChannelsPaginatedRow, error)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListNodesPaginated seems to be unused

Some other ones that could need a check:

  • GetNodesByIDs
  • DeleteNode
  • GetZombieChannelsSCIDs
  • GetPruneHashByHeight
  • GetPruneEntriesForHeights
  • GetClosedChannelsSCIDs
  • ListChannelsWithPoliciesPaginated
  • GetChannelsByIDs

Add two precomputed mapping tables that track the "best" gossip version
for each unique node (pub_key) and channel (SCID):

 - graph_preferred_nodes:    pub_key  -> node_id
 - graph_preferred_channels: scid     -> channel_id

Priority for nodes: v2 announced > v1 announced > v2 shell > v1 shell.
Priority for channels: v2 with policies > v1 with policies > v2 > v1.

These tables enable simple indexed-join queries for cross-version
traversal (ForEachNode, ForEachChannel, ForEachNodeDirectedChannel)
without expensive per-row COALESCE subqueries. The tables are populated
from existing data during the migration and maintained by upsert/delete
queries on every write path (added in the next commit).
Add four new version-agnostic Store methods that return the highest
available gossip version for a channel:

 - FetchChannelEdgesByIDPreferHighest
 - FetchChannelEdgesByOutpointPreferHighest
 - GetVersionsBySCID
 - GetVersionsByOutpoint

The KV store delegates to v1; the SQL store iterates versions from
highest to lowest, returning the first with policies (falling back to
the highest version without).
Add UpsertPreferredNode and UpsertPreferredChannel calls to every Store
write path so the preferred mapping tables stay consistent:

 - upsertSourceNode, upsertNode, maybeCreateShellNode, DeleteNode
 - insertChannel, updateChanEdgePolicy, DeleteChannelEdges
 - pruneGraphNodes

CASCADE deletes on the underlying graph_nodes and graph_channels tables
automatically clean up preferred entries when a version is removed.
Drop the GossipVersion parameter from ForEachNode and ForEachChannel.
Both methods now iterate across all gossip versions, yielding one result
per unique pub_key or SCID using the preferred mapping tables for
pagination.

Wire ChannelGraph.FetchChannelEdgesByID and FetchChannelEdgesByOutpoint
through the PreferHighest variants. Add GetVersionsBySCID and
GetVersionsByOutpoint wrappers to ChannelGraph.
Update ForEachNodeDirectedChannel and FetchNodeFeatures to work across
gossip versions.

ForEachNodeDirectedChannel now checks for a
preferHighestNodeDirectedChanneler interface on the store (implemented
by SQLStore) to stream cross-version directed channels in a single
paginated query against the preferred-channel mapping table. The
fallback path iterates v2 then v1, buffering results to deduplicate by
SCID.

FetchNodeFeatures tries v2 first, falling back to v1 when v2 features
are empty.

The cache population comment is updated to note that v1-then-v2
iteration order naturally gives v2 precedence via key collision
overwrite.
@ellemouton ellemouton force-pushed the g175-graph-db-cross-version-v2 branch from 24c9c8d to b66429f Compare April 10, 2026 06:31
@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Apr 10, 2026
@github-actions
Copy link
Copy Markdown

PR Severity: CRITICAL

Automated classification | 11 files | ~1132 lines changed (excl. tests & generated)

Critical (6 files):

  • sqldb/migrations.go - database migration registry (sqldb/* is always CRITICAL)
  • sqldb/sqlc/migrations/000015_graph_preferred_lookups.up.sql - new SQL migration
  • sqldb/sqlc/migrations/000015_graph_preferred_lookups.down.sql - rollback migration
  • sqldb/sqlc/models.go - sqldb schema models (sqldb/* = CRITICAL)
  • sqldb/sqlc/querier.go - sqldb query interface (sqldb/* = CRITICAL)
  • sqldb/sqlc/queries/graph.sql - raw SQL queries (sqldb/* = CRITICAL)

High (4 files):

  • graph/db/graph.go - network graph DB implementation (graph/*)
  • graph/db/interfaces.go - graph DB interfaces (graph/*)
  • graph/db/kv_store.go - KV-backed graph store (graph/*)
  • graph/db/sql_store.go - SQL-backed graph store (~634 lines, graph/*)

Low (1 file):

  • docs/release-notes/release-notes-0.21.0.md - release notes

Analysis

This PR introduces SQL migration 15 (000015_graph_preferred_lookups) adding new indexes/tables to the graph SQL schema, with corresponding sqlc-generated code and updated graph store implementations. Database migrations are always CRITICAL: they are irreversible in production and affect data integrity.

Key concerns for reviewers:

  • Verify SQL migration correctness, idempotency, and safe rollback via the down migration.
  • sql_store.go changes should match the intent of existing KV store behavior.
  • interfaces.go changes affect all graph DB implementations.

To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

@ellemouton
Copy link
Copy Markdown
Collaborator Author

thanks @bitromortac - addressed 👍

@ellemouton ellemouton requested a review from bitromortac April 10, 2026 09:33
@lightninglabs-deploy
Copy link
Copy Markdown
Collaborator

@bhandras: review reminder
@bitromortac: review reminder
@ellemouton, remember to re-request review from reviewers when ready

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

severity-critical Requires expert review - security/consensus critical

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

4 participants